@herenfor/dsh-wsl 0.0.0-stage → 0.1.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/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "WSL 运行模式",
4
+ "description": "在 Desktop 窗口里把选中的任务放到 WSL 中运行,WSL 里的插件单独管理。",
5
+ "detail": "README.zh.md"
6
+ }
7
+ }
package/merge.mjs ADDED
@@ -0,0 +1,65 @@
1
+ // Pure graph merge for the UI mirror. Kept free of DSH imports so it can be unit-tested.
2
+ import { createHash } from 'node:crypto'
3
+
4
+ /**
5
+ * The two Hosts can work together: DSH of the same major.minor (docs/deployment.md). Patch and prerelease may differ;
6
+ * the setup dialog offers to align them.
7
+ */
8
+ export const compatibleReleases = (a, b) => {
9
+ const pa = /^(\d+)\.(\d+)\./.exec(a?.version ?? ''); const pb = /^(\d+)\.(\d+)\./.exec(b?.version ?? '')
10
+ return !!pa && !!pb && pa[1] === pb[1] && pa[2] === pb[2]
11
+ }
12
+
13
+ /**
14
+ * Merge the user rows of a remote graph into the local one. A remote user row replaces a local row of the same id
15
+ * (one UI for the window: the WSL side wins). Local rows of entries that a WSL plugin's bundle patch switches off
16
+ * (`remote.disabled`, e.g. DS Bot turns off DSH's general settings shell to hold its seat) leave the page too: the
17
+ * patch acts on the WSL Host's composition only, and the page must look as it would there.
18
+ * Every local batch that contained a replaced or dropped row is split into single-row batches for its remaining rows,
19
+ * because a batch script would register those rows' factories too.
20
+ * @param local - this Host's graph.
21
+ * @param remote - { graph, origin, disabled? } from the WSL agent, or undefined.
22
+ * @param order - orderByModuleGraph from dsh-client-modules.
23
+ * @returns { graph, mirrored, dropped } where mirrored maps a package id to its remote row and dropped holds the ids of
24
+ * local rows left out.
25
+ */
26
+ export function mergeGraphs(local, remote, order) {
27
+ const mirrored = new Map()
28
+ const dropped = new Set()
29
+ if (remote === undefined) return { graph: local, mirrored, dropped, reason: 'no remote graph yet' }
30
+ if (!compatibleReleases(local.release, remote.graph.release)) {
31
+ return { graph: local, mirrored, dropped, reason: `release differs: local ${JSON.stringify(local.release)}, remote ${JSON.stringify(remote.graph.release)}` }
32
+ }
33
+ for (const row of remote.graph.entries) if (remote.origin[row.id] === 'user') mirrored.set(row.id, row)
34
+ const byId = new Map(local.entries.map((row) => [row.id, row]))
35
+ for (const { name } of remote.disabled ?? []) if (byId.has(name) && !mirrored.has(name)) dropped.add(name)
36
+ if (mirrored.size === 0 && dropped.size === 0) return { graph: local, mirrored, dropped, reason: 'remote has no user rows' }
37
+ const gone = (id) => mirrored.has(id) || dropped.has(id)
38
+ const entries = order([...local.entries.filter((row) => !gone(row.id)), ...mirrored.values()])
39
+ const batches = []
40
+ for (const batch of local.batches) {
41
+ if (!batch.entries.some(gone)) { batches.push(batch); continue }
42
+ for (const id of batch.entries) {
43
+ if (gone(id)) continue
44
+ const row = byId.get(id)
45
+ batches.push({ phase: batch.phase, url: row.url, rev: row.rev, entries: [id] })
46
+ }
47
+ }
48
+ for (const row of mirrored.values()) batches.push({ phase: 'application', url: row.url, rev: row.rev, entries: [row.id] })
49
+ const rev = createHash('sha256').update(JSON.stringify({ entries, batches })).digest('base64url').slice(0, 12)
50
+ return { graph: { rev, release: local.release, entries, batches }, mirrored, dropped }
51
+ }
52
+
53
+ /**
54
+ * The package ids a /plugins request addresses: every id of a combo (`/plugins/??a/client.js,b/client.js&rev=`),
55
+ * or the package of a chunk or asset path (`/plugins/<id>/...`, scoped ids included).
56
+ */
57
+ export function requestedPackages(url) {
58
+ const { pathname, search } = new URL(url, 'http://x')
59
+ if (search.startsWith('??')) {
60
+ const list = search.slice(2).split('&')[0]
61
+ return list.split(',').map((file) => file.replace(/\/client\.js(\.map)?$/, ''))
62
+ }
63
+ const parts = pathname.replace(/^\/plugins\//, '').split('/')
64
+ return [parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]]
65
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@herenfor/dsh-wsl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "WSL run mode for DeepSeek Harness Desktop on Windows: run chosen tasks, and the plugins they use, in WSL from the Desktop window",
5
+ "license": "MIT",
6
+ "icon": "./icon.svg",
7
+ "type": "module",
8
+ "keywords": [
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "dsh-plugin",
12
+ "wsl"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/herenfor/dsh-wsl.git",
17
+ "directory": "experiments/mirror/mirror"
18
+ },
19
+ "homepage": "https://github.com/herenfor/dsh-wsl#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/herenfor/dsh-wsl/issues"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "dsh": ">=0.2.0-0 <0.3.0-0"
28
+ },
29
+ "exports": {
30
+ ".": "./index.mjs",
31
+ "./client": "./client.js",
32
+ "./cordis.patch.yml": "./cordis.patch.yml",
33
+ "./locale/*.json": "./locale/*.json",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-locale",
44
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
45
+ "@deepseek-ai/dsh-client-ui-workspace",
46
+ "@deepseek-ai/dsh-client-ui-conversation"
47
+ ]
48
+ },
49
+ "manifestVersion": 1
50
+ },
51
+ "files": [
52
+ "*.mjs",
53
+ "client.js",
54
+ "cordis.patch.yml",
55
+ "icon.svg",
56
+ "locale/*.json",
57
+ "README.zh.md",
58
+ "transport/",
59
+ "agent/"
60
+ ]
61
+ }
package/paths.mjs ADDED
@@ -0,0 +1,24 @@
1
+ // Local-directory install specs between Windows and WSL. A spec may carry pnpm's `file:` or `link:` prefix.
2
+ // Kept free of DSH imports so it can be unit-tested.
3
+
4
+ const split = (spec) => { const m = /^(file:|link:)?(.*)$/s.exec(spec); return [m[1] ?? '', m[2]] }
5
+
6
+ /** A Windows path (C:\..., \\wsl.localhost\<distro>\..., \\wsl$\<distro>\...) as WSL sees it; other specs unchanged. */
7
+ export function toWslSpec(spec) {
8
+ const [prefix, path] = split(spec.trim())
9
+ const drive = /^([A-Za-z]):[\\/](.*)$/s.exec(path)
10
+ if (drive) return `${prefix}/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}`.replace(/\/+$/, '')
11
+ const unc = /^[\\/]{2}(?:wsl\.localhost|wsl\$)[\\/][^\\/]+[\\/]?(.*)$/is.exec(path)
12
+ if (unc) return `${prefix}/${unc[1].replace(/\\/g, '/')}`.replace(/\/+$/, '') || `${prefix}/`
13
+ return spec
14
+ }
15
+
16
+ /** A WSL path as Windows sees it (/mnt/c/... → C:\..., /home/... → \\wsl.localhost\<distro>\home\...); other specs unchanged. */
17
+ export function toWindowsSpec(spec, distro) {
18
+ const [prefix, path] = split(spec.trim())
19
+ if (!path.startsWith('/')) return spec
20
+ const mnt = /^\/mnt\/([a-z])(?:\/(.*))?$/s.exec(path)
21
+ if (mnt) return `${prefix}${mnt[1].toUpperCase()}:\\${(mnt[2] ?? '').replace(/\//g, '\\')}`
22
+ if (!distro) return spec
23
+ return `${prefix}\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`
24
+ }
package/plugins.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // The WSL tab of the Plugins page: which of the WSL Host's bundles it lists, and what it shows of each. The official
2
+ // Installed tab is left to this Host alone. Kept free of DSH imports so it can be unit-tested.
3
+
4
+ // Bundles the WSL side needs to be reached at all: listed nowhere, so they cannot be switched off from the page.
5
+ const INTERNAL = new Set(['dsh-wsl-agent'])
6
+
7
+ // Package text as the manifest gives it (a string, or translations by lowercase locale with English among them); the
8
+ // page resolves it in the active language. A map without English gets one, so the page's resolver always has it.
9
+ const text = (value, fallback) => {
10
+ if (typeof value === 'string') return value
11
+ if (value && typeof value === 'object') {
12
+ const entries = Object.entries(value).filter(([, v]) => typeof v === 'string')
13
+ if (entries.length) return { en: value.en ?? entries[0][1], ...Object.fromEntries(entries) }
14
+ }
15
+ return fallback
16
+ }
17
+
18
+ /**
19
+ * The rows of the WSL tab, as the official Installed tab picks them: the official optional bundles and the installed
20
+ * ones (the profile's own base bundles are neither), installed ones first.
21
+ */
22
+ export function wslBundles(bundles) {
23
+ return bundles
24
+ .filter((b) => !INTERNAL.has(b.name) && (b.official || b.installed))
25
+ .map((b) => ({
26
+ name: b.name,
27
+ title: text(b.meta?.title, b.name),
28
+ description: text(b.meta?.description ?? b.description, ''),
29
+ icon: typeof b.meta?.icon === 'string' ? b.meta.icon : undefined,
30
+ version: b.version ?? null,
31
+ official: !!b.official,
32
+ installed: !!b.installed,
33
+ enabled: !!b.enabled,
34
+ availability: b.availability,
35
+ ...(b.error ? { error: b.error.message ?? String(b.error.code ?? b.error) } : {}),
36
+ }))
37
+ .sort((a, b) => Number(a.official) - Number(b.official))
38
+ }
39
+
40
+ // Official bundles the official page marks as experimental (its own list, by name).
41
+ const EXPERIMENTAL = /^@deepseek-ai\/dsh-(experimental-|cua-bundle$|personalized-recommendations$)/
42
+
43
+ /**
44
+ * One bundle's page in the WSL tab, as the official bundle page shows it: the bundle with its rows joined to the
45
+ * WSL Host's live entries (`listPlugins`).
46
+ * @returns undefined when the WSL Host lists no such bundle, or one the tab does not list.
47
+ */
48
+ export function wslBundlePage(bundles, plugins, name) {
49
+ const bundle = bundles.find((b) => b.name === name)
50
+ if (bundle === undefined || INTERNAL.has(name)) return undefined
51
+ const [card] = wslBundles([bundle])
52
+ if (card === undefined) return undefined
53
+ const rows = (bundle.rows ?? []).map((row) => {
54
+ const live = row.entryId === undefined ? undefined : plugins.find((p) => p.entryId === row.entryId)
55
+ return {
56
+ rowId: row.rowId,
57
+ moduleName: row.moduleName,
58
+ title: text(row.meta?.title, row.rowId),
59
+ description: text(row.meta?.description, ''),
60
+ icon: typeof row.meta?.icon === 'string' ? row.meta.icon : undefined,
61
+ enabled: live?.enabled ?? false,
62
+ phase: live?.fiberPhase ?? null,
63
+ ...(live?.diagnostic ? { diagnostic: live.diagnostic } : {}),
64
+ ...(row.entryId !== undefined ? { entryId: row.entryId } : {}),
65
+ // The official page locks a row the Host cannot address through the profile patch.
66
+ locked: row.entryId === undefined || live?.readOnlyReason !== undefined,
67
+ }
68
+ })
69
+ return {
70
+ ...card,
71
+ experimental: card.official && EXPERIMENTAL.test(name),
72
+ removable: !!bundle.removable && !card.official,
73
+ ...(bundle.source ? { source: bundle.source } : {}),
74
+ ...(bundle.readOnlyReason ? { readOnly: true } : {}),
75
+ rows,
76
+ }
77
+ }
package/remote-mux.mjs ADDED
@@ -0,0 +1,154 @@
1
+ // Streams on the WSL DSH Host: one WebSocket to its /api/remote.mux (DSH's JSON text mux), opened over the bridge.
2
+ // A minimal WebSocket client (RFC 6455, text frames, no extensions) so nothing depends on where the Desktop install
3
+ // keeps the `ws` package.
4
+ import { randomBytes, randomUUID, createHash } from 'node:crypto'
5
+
6
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
7
+
8
+ /** Error of a remote stream, in the form DSH's gateway passes to the page unchanged. */
9
+ export const remoteError = (failure) => Object.assign(new Error(failure?.message || 'WSL stream failed'), {
10
+ isDSHRemoteError: true, code: failure?.code || 'internal', details: failure?.details ?? {},
11
+ })
12
+ const carrierError = (message) => remoteError({ code: 'gateway/carrier', message })
13
+
14
+ function encode(opcode, payload) {
15
+ const len = payload.length
16
+ const head = len < 126 ? Buffer.from([0x80 | opcode, 0x80 | len])
17
+ : len < 65536 ? Buffer.from([0x80 | opcode, 0x80 | 126, len >> 8, len & 255])
18
+ : (() => { const b = Buffer.alloc(10); b[0] = 0x80 | opcode; b[1] = 0x80 | 127; b.writeBigUInt64BE(BigInt(len), 2); return b })()
19
+ const mask = randomBytes(4)
20
+ const body = Buffer.alloc(len)
21
+ for (let i = 0; i < len; i++) body[i] = payload[i] ^ mask[i & 3]
22
+ return Buffer.concat([head, mask, body])
23
+ }
24
+
25
+ class Socket {
26
+ constructor(socket, head, onText, onClose) {
27
+ this.socket = socket
28
+ this.buffer = head?.length ? Buffer.from(head) : Buffer.alloc(0)
29
+ this.fragments = []
30
+ this.open = true
31
+ socket.on('data', (chunk) => { this.buffer = Buffer.concat([this.buffer, chunk]); this.drain(onText) })
32
+ const closed = () => { if (!this.open) return; this.open = false; onClose() }
33
+ socket.once('close', closed)
34
+ socket.once('error', closed)
35
+ if (this.buffer.length) queueMicrotask(() => this.drain(onText))
36
+ }
37
+ drain(onText) {
38
+ for (;;) {
39
+ const b = this.buffer
40
+ if (b.length < 2) return
41
+ const fin = (b[0] & 0x80) !== 0
42
+ const opcode = b[0] & 0x0f
43
+ const masked = (b[1] & 0x80) !== 0
44
+ let len = b[1] & 0x7f
45
+ let offset = 2
46
+ if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); offset = 4 } else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); offset = 10 }
47
+ const maskAt = offset
48
+ if (masked) offset += 4
49
+ if (b.length < offset + len) return
50
+ let payload = b.subarray(offset, offset + len)
51
+ if (masked) { const m = b.subarray(maskAt, maskAt + 4); payload = Buffer.from(payload.map((x, i) => x ^ m[i & 3])) }
52
+ this.buffer = b.subarray(offset + len)
53
+ if (opcode === 0x9) this.write(0xa, payload)
54
+ else if (opcode === 0x8) { this.write(0x8, Buffer.alloc(0)); this.socket.end(); return }
55
+ else if (opcode === 0x1 || opcode === 0x0) {
56
+ this.fragments.push(Buffer.from(payload))
57
+ if (fin) { const text = Buffer.concat(this.fragments).toString('utf8'); this.fragments = []; onText(text) }
58
+ }
59
+ }
60
+ }
61
+ write(opcode, payload) { if (this.open && !this.socket.destroyed) this.socket.write(encode(opcode, payload)) }
62
+ send(text) { this.write(0x1, Buffer.from(text, 'utf8')) }
63
+ close() { if (this.open) { this.write(0x8, Buffer.alloc(0)); this.socket.end() } this.socket.destroy() }
64
+ }
65
+
66
+ /**
67
+ * @param upgrade - async () => ({ socket, head }): performs the HTTP upgrade to /api/remote.mux on the WSL Host.
68
+ */
69
+ export class WslMux {
70
+ constructor(upgrade) {
71
+ this.upgrade = upgrade
72
+ this.streams = new Map()
73
+ }
74
+ connect() {
75
+ if (this.socket?.open) return Promise.resolve()
76
+ return (this.connecting ??= (async () => {
77
+ const { socket, head } = await this.upgrade()
78
+ this.socket = new Socket(socket, head, (text) => this.receive(text), () => {
79
+ this.socket = undefined
80
+ for (const inbox of this.streams.values()) inbox.fail(carrierError('The connection to the WSL Host closed.'))
81
+ })
82
+ })().finally(() => { this.connecting = undefined }))
83
+ }
84
+ receive(text) {
85
+ let frame
86
+ try { frame = JSON.parse(text) } catch { return }
87
+ this.streams.get(frame.streamId)?.push(frame)
88
+ }
89
+ /** Open one stream on the WSL Host; yields its items, throws its error. */
90
+ async *open(endpoint, payload, signal, uplink) {
91
+ await this.connect()
92
+ signal?.throwIfAborted()
93
+ const id = randomUUID()
94
+ const inbox = new Inbox()
95
+ this.streams.set(id, inbox)
96
+ const socket = this.socket
97
+ const send = (frame) => socket.send(JSON.stringify({ streamId: id, ...frame }))
98
+ const abort = () => inbox.fail(signal.reason ?? new Error('aborted'))
99
+ signal?.addEventListener('abort', abort, { once: true })
100
+ send({ type: 'open', endpoint, payload })
101
+ let stopped = false
102
+ let iterator
103
+ if (uplink) {
104
+ iterator = uplink[Symbol.asyncIterator]()
105
+ void (async () => {
106
+ while (!stopped) {
107
+ const item = await iterator.next()
108
+ if (stopped) break
109
+ if (item.done) { send({ type: 'end' }); break }
110
+ send({ type: 'item', value: item.value })
111
+ }
112
+ })().catch((error) => inbox.fail(error))
113
+ }
114
+ let finished = false
115
+ try {
116
+ for (;;) {
117
+ const frame = await inbox.next()
118
+ if (frame.type === 'item') yield frame.value
119
+ else if (frame.type === 'error') { finished = true; throw remoteError(frame.error) }
120
+ else { finished = true; return }
121
+ }
122
+ } finally {
123
+ stopped = true
124
+ signal?.removeEventListener('abort', abort)
125
+ if (!finished && socket.open) send({ type: 'cancel' })
126
+ this.streams.delete(id)
127
+ void iterator?.return?.()
128
+ }
129
+ }
130
+ close() { this.socket?.close() }
131
+ }
132
+
133
+ class Inbox {
134
+ frames = [];
135
+ push(frame) { this.frames.push(frame); this.wake?.() }
136
+ fail(error) { this.error ??= error; this.wake?.() }
137
+ async next() {
138
+ while (!this.frames.length) {
139
+ if (this.error) throw this.error
140
+ await new Promise((resolve) => { this.wake = resolve })
141
+ this.wake = undefined
142
+ }
143
+ return this.frames.shift()
144
+ }
145
+ }
146
+
147
+ /** The headers and accept check of a client WebSocket upgrade. */
148
+ export function upgradeHeaders() {
149
+ const key = randomBytes(16).toString('base64')
150
+ return {
151
+ headers: { connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-version': '13', 'sec-websocket-key': key },
152
+ accept: createHash('sha1').update(key + GUID).digest('base64'),
153
+ }
154
+ }
package/routes.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Which WSL Host routes the Desktop Host forwards. Kept free of DSH imports so it can be unit-tested.
2
+
3
+ // Paths DSH itself owns on every Host; never forwarded even when only the WSL Host has them.
4
+ const RESERVED = [/^\/$/, /^\/api(\/|$)/, /^\/plugins(\/|$)/, /^\/connect$/, /^\/wsl-bridge(\/|$)/, /^\/assets(\/|$)/]
5
+ export const isReserved = (path) => RESERVED.some((re) => re.test(path))
6
+
7
+ /**
8
+ * Routes the WSL Host serves and the local Host does not: with the same DSH version and the same official bundles
9
+ * on both sides, these are the routes of plugins installed only in WSL.
10
+ * @param remote - { exact: string[], prefix: string[] } from the WSL agent.
11
+ * @param local - { exact: Set|string[], prefix: Set|string[] } of this Host, without the routes the mirror added.
12
+ * @returns [{ kind, path }] in a stable order.
13
+ */
14
+ export function routesToForward(remote, local) {
15
+ const out = []
16
+ for (const kind of ['exact', 'prefix']) {
17
+ const mine = new Set(local?.[kind] ?? [])
18
+ for (const path of [...(remote?.[kind] ?? [])].sort()) if (!mine.has(path) && !isReserved(path)) out.push({ kind, path })
19
+ }
20
+ return out
21
+ }
22
+
23
+ // Request headers passed to the WSL Host; cookie, host and origin are replaced with the WSL Host's own.
24
+ export const FORWARD_REQUEST_HEADERS = ['accept', 'accept-language', 'content-type', 'cache-control', 'if-none-match', 'if-modified-since', 'last-event-id', 'range']
25
+ // Response headers passed back; set-cookie is dropped so the WSL Host's cookie never reaches the page.
26
+ export const DROP_RESPONSE_HEADERS = new Set(['set-cookie', 'connection', 'keep-alive', 'transfer-encoding', 'content-encoding', 'content-length'])
27
+
28
+ export const isJson = (type) => typeof type === 'string' && /^application\/(?:[\w.+-]*\+)?json\b/i.test(type.trim())
29
+
30
+ /**
31
+ * A chunk rewriter for a Server-Sent Events body: each `data:` line holding JSON goes through `map`. Lines are cut at
32
+ * line ends, so a partial line waits for the next chunk.
33
+ */
34
+ export function eventStreamRewriter(map) {
35
+ const decoder = new TextDecoder()
36
+ let pending = ''
37
+ return (chunk) => {
38
+ pending += decoder.decode(chunk, { stream: true })
39
+ const end = pending.lastIndexOf('\n')
40
+ if (end < 0) return Buffer.alloc(0)
41
+ const lines = pending.slice(0, end + 1)
42
+ pending = pending.slice(end + 1)
43
+ return Buffer.from(lines.replace(/^data:( ?)(.*)$/gm, (line, space, data) => {
44
+ try { return `data:${space}${JSON.stringify(map(JSON.parse(data)))}` } catch { return line }
45
+ }))
46
+ }
47
+ }