dsh-vscode-bridge-he 0.0.0-stage → 1.0.5
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/README.md +99 -2
- package/assets/icon-180.png +0 -0
- package/client.js +254 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +12 -0
- package/index.js +567 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -4
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DSH VSCode Bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send a VS Code selection or a file path into the conversation you already have open in
|
|
4
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) — as an `@path`
|
|
5
|
+
reference, inserted at the composer caret. It does **not** send the message for you.
|
|
6
|
+
|
|
7
|
+
> **This package is only the DSH half.** It needs the companion VS Code extension to do
|
|
8
|
+
> anything; installing this alone gives you an idle bridge. Both halves are in the
|
|
9
|
+
> [repository](https://github.com/heyitaowml-ctrl/dsh-vscode-bridge):
|
|
10
|
+
> `dsh-vscode-bridge/` (this package) and `vscode-dsh-bridge/` (the extension).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
**1 · DSH half** — one command, then refresh the DSH page once:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin --profile <profile> add dsh-vscode-bridge-he
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**2 · VS Code half** — pick one:
|
|
21
|
+
|
|
22
|
+
- **Extensions view:** open VS Code → Extensions → `…` → **Install from VSIX…** → choose
|
|
23
|
+
[`dist/dsh-vscode-bridge.vsix`](https://github.com/heyitaowml-ctrl/dsh-vscode-bridge/raw/master/dist/dsh-vscode-bridge.vsix)
|
|
24
|
+
- **Terminal:**
|
|
25
|
+
```sh
|
|
26
|
+
code --install-extension dsh-vscode-bridge.vsix
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Reload the VS Code window afterwards (`Ctrl+Shift+P` → **Developer: Reload Window**); the
|
|
30
|
+
commands appear under **DSH** in the editor and Explorer context menus.
|
|
31
|
+
|
|
32
|
+
The extension discovers the Host by itself: DSH writes its endpoint and token to
|
|
33
|
+
`~/.dsh/vscode-bridge.json` (override with `DSH_VSCODE_BRIDGE_FILE`) on every start, so
|
|
34
|
+
there is no token to copy by hand.
|
|
35
|
+
|
|
36
|
+
### Finding out whether the extension is connected
|
|
37
|
+
|
|
38
|
+
The composer shows a small `⚠ VS Code 未连接` pill until the extension has sent its first
|
|
39
|
+
reference. It disappears on first use and stays hidden afterwards; the `×` dismisses it for
|
|
40
|
+
the current page.
|
|
41
|
+
|
|
42
|
+
Zero references is the only signal the Host has, so the pill cannot tell "extension not
|
|
43
|
+
installed" apart from "installed but never used" — it deliberately says *not connected*
|
|
44
|
+
rather than *not installed*. If you have just installed the extension and the pill stays,
|
|
45
|
+
reload the VS Code window.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
- Select lines, right-click in the editor → **DSH: 发送选中代码到 DSH 对话**
|
|
50
|
+
- Right-click a file or folder in the Explorer → **DSH: 发送文件路径到 DSH 对话**
|
|
51
|
+
- Switch back to DSH: the reference is already at the caret, and nothing has been sent.
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
@C:\project\src\main.ts (lines 12-28)
|
|
55
|
+
@C:\project\src\main.ts
|
|
56
|
+
@"C:\project\my folder\main.ts" (line 7)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Line markers are inclusive and 1-based; the prompt section `vscode-references` tells the
|
|
60
|
+
model to read that range rather than guess.
|
|
61
|
+
|
|
62
|
+
## Architecture
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
VS Code extension
|
|
66
|
+
└─ HTTP POST /dsh-vscode/attach (Bearer token)
|
|
67
|
+
└─ DSH Host plugin (this package)
|
|
68
|
+
└─ SSE /dsh-vscode/events
|
|
69
|
+
└─ DSH browser plugin
|
|
70
|
+
└─ conversation.input.left → InputActions.insertText()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
When no DSH conversation is open, attachments are queued in memory and flushed to the next
|
|
74
|
+
browser session that connects.
|
|
75
|
+
|
|
76
|
+
## Endpoints
|
|
77
|
+
|
|
78
|
+
| Method | Path | Auth | Purpose |
|
|
79
|
+
| --- | --- | --- | --- |
|
|
80
|
+
| POST | `/dsh-vscode/attach` | Bearer token | Queue or deliver one reference |
|
|
81
|
+
| GET | `/dsh-vscode/events` | `token` + `channel` query | SSE stream consumed by the browser half |
|
|
82
|
+
| GET | `/dsh-vscode/config` | same-origin page | Supplies the browser half with the current token/channel |
|
|
83
|
+
| GET | `/dsh-vscode/health` | none | Connection, queue and `attachments` count for the status command |
|
|
84
|
+
|
|
85
|
+
`/health` and the SSE `hello` frame both report `attachments` (how many references the
|
|
86
|
+
extension has ever sent to this Host instance) and `lastAttachAt`. That pair drives the
|
|
87
|
+
composer hint above.
|
|
88
|
+
|
|
89
|
+
## Notes
|
|
90
|
+
|
|
91
|
+
- Paths are sent absolute, so DSH does not have to share a workspace with VS Code.
|
|
92
|
+
- The browser half renders nothing except the connection hint.
|
|
93
|
+
- **The bridge is loopback-only by design.** The Host binds locally, every request carries a
|
|
94
|
+
per-process bearer token, and the token file is regenerated on each start.
|
|
95
|
+
- This package is unrelated to [`dsh-vscode-bridge`](https://www.npmjs.com/package/dsh-vscode-bridge)
|
|
96
|
+
on npm, which is a different plugin by a different author.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MulanPSL-2.0
|
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-vscode-bridge-he',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
|
|
6
|
+
const RETRY_MS = 500;
|
|
7
|
+
const MAX_SEEN = 200;
|
|
8
|
+
// Where the install hint points. Kept in sync with the README's Install section.
|
|
9
|
+
const INSTALL_DOC_URL = 'https://github.com/heyitaowml-ctrl/dsh-vscode-bridge#install';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Invisible composer participant: it owns the SSE subscription for the
|
|
13
|
+
* current session and inserts incoming VSCode references at the live caret
|
|
14
|
+
* through the composer's InputActions. Returning null keeps the DSH UI
|
|
15
|
+
* unchanged.
|
|
16
|
+
*
|
|
17
|
+
* This is registered under `conversation.input.left` rather than
|
|
18
|
+
* `conversation.composer.dock`: the dock is rendered only when the
|
|
19
|
+
* composer variant is active, so a brand-new session (hero variant) would
|
|
20
|
+
* not subscribe until its first message and the Host could only queue the
|
|
21
|
+
* reference. The left tool slot renders in both variants and receives the
|
|
22
|
+
* same `sessionId`, `inputActions`, and `useInput` props.
|
|
23
|
+
*/
|
|
24
|
+
function VSCodeBridgeSlot(props) {
|
|
25
|
+
const sessionId = props.sessionId;
|
|
26
|
+
const inputActions = props.inputActions;
|
|
27
|
+
// The dock slot guarantees useInput; subscribing to the draft lets the
|
|
28
|
+
// insertion add a separator only when the current draft needs one.
|
|
29
|
+
const draft = props.useInput((snapshot) => snapshot.draft);
|
|
30
|
+
|
|
31
|
+
// How many references the VS Code extension has ever sent to this Host
|
|
32
|
+
// instance. `null` means the SSE handshake has not answered yet.
|
|
33
|
+
// Zero is the only honest signal we have: it cannot tell "extension not
|
|
34
|
+
// installed" apart from "installed but never used", so the hint says
|
|
35
|
+
// 未连接 (not connected) rather than 未安装 (not installed).
|
|
36
|
+
const [attachments, setAttachments] = React.useState(null);
|
|
37
|
+
const [dismissed, setDismissed] = React.useState(false);
|
|
38
|
+
|
|
39
|
+
const queueRef = React.useRef([]);
|
|
40
|
+
const seenRef = React.useRef(new Set());
|
|
41
|
+
const timerRef = React.useRef(null);
|
|
42
|
+
const mountedRef = React.useRef(true);
|
|
43
|
+
const flushRef = React.useRef(() => {});
|
|
44
|
+
|
|
45
|
+
flushRef.current = () => {
|
|
46
|
+
if (!mountedRef.current) return;
|
|
47
|
+
if (timerRef.current !== null) {
|
|
48
|
+
clearTimeout(timerRef.current);
|
|
49
|
+
timerRef.current = null;
|
|
50
|
+
}
|
|
51
|
+
if (inputActions === undefined || inputActions === null
|
|
52
|
+
|| typeof inputActions.captureInsertion !== 'function'
|
|
53
|
+
|| typeof inputActions.insertText !== 'function') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
while (queueRef.current.length > 0) {
|
|
57
|
+
const text = queueRef.current[0];
|
|
58
|
+
let inserted = false;
|
|
59
|
+
try {
|
|
60
|
+
const span = inputActions.captureInsertion();
|
|
61
|
+
const separator = typeof draft === 'string' && draft !== '' && !/\s$/u.test(draft) ? ' ' : '';
|
|
62
|
+
inserted = inputActions.insertText(`${separator}${text} `, span) === true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.warn('[dsh-vscode-bridge] failed to insert reference', error);
|
|
65
|
+
}
|
|
66
|
+
if (!inserted) break;
|
|
67
|
+
queueRef.current.shift();
|
|
68
|
+
}
|
|
69
|
+
if (queueRef.current.length > 0 && timerRef.current === null) {
|
|
70
|
+
timerRef.current = setTimeout(() => {
|
|
71
|
+
timerRef.current = null;
|
|
72
|
+
flushRef.current();
|
|
73
|
+
}, RETRY_MS);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
mountedRef.current = true;
|
|
79
|
+
flushRef.current();
|
|
80
|
+
return () => {
|
|
81
|
+
mountedRef.current = false;
|
|
82
|
+
if (timerRef.current !== null) {
|
|
83
|
+
clearTimeout(timerRef.current);
|
|
84
|
+
timerRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}, [inputActions]);
|
|
88
|
+
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
if (sessionId === undefined || sessionId === null || sessionId === '') return undefined;
|
|
91
|
+
let closed = false;
|
|
92
|
+
let source;
|
|
93
|
+
let onAttachment;
|
|
94
|
+
let onHello;
|
|
95
|
+
|
|
96
|
+
const connect = async () => {
|
|
97
|
+
let bridge = typeof window === 'undefined' ? undefined : window.__DSH_VSCODE_BRIDGE__;
|
|
98
|
+
if (bridge === undefined || bridge === null || !bridge.token || !bridge.channel) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch('/dsh-vscode/config', {
|
|
101
|
+
cache: 'no-store',
|
|
102
|
+
credentials: 'same-origin',
|
|
103
|
+
headers: { accept: 'application/json' },
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
106
|
+
bridge = await response.json();
|
|
107
|
+
if (typeof window !== 'undefined' && bridge !== null && bridge.token && bridge.channel) {
|
|
108
|
+
window.__DSH_VSCODE_BRIDGE__ = bridge;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn('[dsh-vscode-bridge] cannot load bridge config; reload DSH after installing the plugin', error);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (closed || bridge === undefined || bridge === null || !bridge.token || !bridge.channel) return;
|
|
116
|
+
|
|
117
|
+
let url;
|
|
118
|
+
try {
|
|
119
|
+
url = new URL('/dsh-vscode/events', window.location.origin);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.warn('[dsh-vscode-bridge] cannot build bridge URL', error);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
url.searchParams.set('sessionId', sessionId);
|
|
125
|
+
url.searchParams.set('channel', bridge.channel);
|
|
126
|
+
url.searchParams.set('token', bridge.token);
|
|
127
|
+
|
|
128
|
+
source = new EventSource(url.toString());
|
|
129
|
+
onAttachment = (event) => {
|
|
130
|
+
try {
|
|
131
|
+
const item = JSON.parse(event.data);
|
|
132
|
+
if (item === null || typeof item !== 'object' || typeof item.text !== 'string') return;
|
|
133
|
+
const id = String(item.id ?? '');
|
|
134
|
+
if (id !== '') {
|
|
135
|
+
if (seenRef.current.has(id)) return;
|
|
136
|
+
seenRef.current.add(id);
|
|
137
|
+
if (seenRef.current.size > MAX_SEEN) {
|
|
138
|
+
const oldest = seenRef.current.values().next().value;
|
|
139
|
+
if (oldest !== undefined) seenRef.current.delete(oldest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
queueRef.current.push(item.text);
|
|
143
|
+
setAttachments((current) => (current === null ? 1 : current + 1));
|
|
144
|
+
flushRef.current();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.warn('[dsh-vscode-bridge] invalid attachment event', error);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
onHello = (event) => {
|
|
150
|
+
try {
|
|
151
|
+
const hello = JSON.parse(event.data);
|
|
152
|
+
console.info('[dsh-vscode-bridge] connected', hello);
|
|
153
|
+
const reported = Number(hello?.attachments ?? 0);
|
|
154
|
+
setAttachments((current) => {
|
|
155
|
+
const known = Number.isFinite(reported) && reported > 0 ? reported : 0;
|
|
156
|
+
if (current === null) return known;
|
|
157
|
+
return Math.max(current, known);
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// A malformed hello is not worth surfacing.
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
source.addEventListener('attachment', onAttachment);
|
|
165
|
+
source.addEventListener('hello', onHello);
|
|
166
|
+
source.addEventListener('error', () => {
|
|
167
|
+
// EventSource retries automatically; keep the queue intact.
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
void connect();
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
closed = true;
|
|
175
|
+
if (source !== undefined) {
|
|
176
|
+
if (onAttachment !== undefined) source.removeEventListener('attachment', onAttachment);
|
|
177
|
+
if (onHello !== undefined) source.removeEventListener('hello', onHello);
|
|
178
|
+
source.close();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}, [sessionId]);
|
|
182
|
+
|
|
183
|
+
if (dismissed || attachments === null || attachments > 0) return null;
|
|
184
|
+
|
|
185
|
+
// Small, theme-neutral pill: it inherits the surrounding text colour and
|
|
186
|
+
// only lowers its own opacity, so it stays readable in light and dark.
|
|
187
|
+
return React.createElement(
|
|
188
|
+
'span',
|
|
189
|
+
{
|
|
190
|
+
style: {
|
|
191
|
+
display: 'inline-flex',
|
|
192
|
+
alignItems: 'center',
|
|
193
|
+
gap: 2,
|
|
194
|
+
height: 22,
|
|
195
|
+
padding: '0 2px 0 8px',
|
|
196
|
+
marginRight: 4,
|
|
197
|
+
fontSize: 11,
|
|
198
|
+
lineHeight: '22px',
|
|
199
|
+
borderRadius: 999,
|
|
200
|
+
border: '1px solid currentColor',
|
|
201
|
+
opacity: 0.6,
|
|
202
|
+
whiteSpace: 'nowrap',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
React.createElement(
|
|
206
|
+
'a',
|
|
207
|
+
{
|
|
208
|
+
href: INSTALL_DOC_URL,
|
|
209
|
+
target: '_blank',
|
|
210
|
+
rel: 'noreferrer noopener',
|
|
211
|
+
title: '还没收到过 VS Code 扩展发来的引用。没装过就点这里看安装步骤;装过的话,请在 VS Code 里执行 Developer: Reload Window。',
|
|
212
|
+
style: { color: 'inherit', textDecoration: 'none', cursor: 'pointer' },
|
|
213
|
+
onClick: (event) => event.stopPropagation(),
|
|
214
|
+
},
|
|
215
|
+
'⚠ VS Code 未连接',
|
|
216
|
+
),
|
|
217
|
+
React.createElement(
|
|
218
|
+
'button',
|
|
219
|
+
{
|
|
220
|
+
type: 'button',
|
|
221
|
+
title: '不再提示',
|
|
222
|
+
'aria-label': 'dismiss the VS Code extension hint',
|
|
223
|
+
onClick: (event) => {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
event.stopPropagation();
|
|
226
|
+
setDismissed(true);
|
|
227
|
+
},
|
|
228
|
+
style: {
|
|
229
|
+
border: 'none',
|
|
230
|
+
background: 'transparent',
|
|
231
|
+
color: 'inherit',
|
|
232
|
+
cursor: 'pointer',
|
|
233
|
+
fontSize: 13,
|
|
234
|
+
lineHeight: '18px',
|
|
235
|
+
padding: '0 4px',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
'×',
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
inject: ['slots'],
|
|
245
|
+
apply(ctx) {
|
|
246
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
247
|
+
name: 'conversation.input.left',
|
|
248
|
+
id: 'vscode-bridge',
|
|
249
|
+
order: 90,
|
|
250
|
+
}, VSCodeBridgeSlot));
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="VSCode bridge">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#1b6ef3"/>
|
|
5
|
+
<stop offset="1" stop-color="#0a3fa8"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="3" y="3" width="58" height="58" rx="15" fill="url(#bg)"/>
|
|
9
|
+
<path d="M22 20 12 32l10 12" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
10
|
+
<path d="M42 20l10 12-10 12" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<path d="M36 15 28 49" fill="none" stroke="#9fd0ff" stroke-width="4" stroke-linecap="round"/>
|
|
12
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DSH VSCode Bridge (Host half).
|
|
8
|
+
*
|
|
9
|
+
* The VSCode extension POSTs a selection/file reference to /dsh-vscode/attach.
|
|
10
|
+
* The browser half of this plugin keeps an SSE stream open and inserts the
|
|
11
|
+
* reference text into the current composer through InputActions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const inject = ['webServer'];
|
|
15
|
+
|
|
16
|
+
const ROUTE_PREFIX = '/dsh-vscode';
|
|
17
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
18
|
+
const MAX_QUEUE = 100;
|
|
19
|
+
const MAX_HISTORY = 50;
|
|
20
|
+
const HEARTBEAT_MS = 20_000;
|
|
21
|
+
const PUBLISH_RETRY_MS = 100;
|
|
22
|
+
const PUBLISH_MAX_ATTEMPTS = 100;
|
|
23
|
+
|
|
24
|
+
const EDITOR_REFERENCES_PROMPT = [
|
|
25
|
+
'## VSCode references',
|
|
26
|
+
'A message may contain a reference added from VSCode, written as `@<absolute path>` or',
|
|
27
|
+
'`@"<absolute path>"` and optionally followed by `(line N)` or `(lines A-B)`. The path is',
|
|
28
|
+
'absolute on the host. When a line marker is present, read that inclusive range from the',
|
|
29
|
+
'file (use the read tool with offset/limit) before answering; do not claim to have',
|
|
30
|
+
'inspected the file otherwise.',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
function bridgeFilePath() {
|
|
34
|
+
return process.env.DSH_VSCODE_BRIDGE_FILE || join(homedir(), '.dsh', 'vscode-bridge.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function tokenMatches(expected, candidate) {
|
|
38
|
+
if (typeof candidate !== 'string' || candidate.length === 0) return false;
|
|
39
|
+
const left = Buffer.from(expected, 'utf8');
|
|
40
|
+
const right = Buffer.from(candidate, 'utf8');
|
|
41
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function bearerToken(req) {
|
|
45
|
+
const header = req.headers.authorization;
|
|
46
|
+
if (typeof header !== 'string') return '';
|
|
47
|
+
const match = /^Bearer\s+(.+)$/iu.exec(header);
|
|
48
|
+
return match?.[1]?.trim() ?? '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJsonBody(req) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let settled = false;
|
|
54
|
+
let size = 0;
|
|
55
|
+
const chunks = [];
|
|
56
|
+
const fail = (error) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
reject(error);
|
|
60
|
+
};
|
|
61
|
+
req.on('data', (chunk) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (size > MAX_BODY_BYTES) {
|
|
65
|
+
fail(new Error('body_too_large'));
|
|
66
|
+
try {
|
|
67
|
+
req.destroy();
|
|
68
|
+
} catch {
|
|
69
|
+
// The peer may already be gone.
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
req.on('end', () => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
try {
|
|
79
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
80
|
+
resolve(text === '' ? {} : JSON.parse(text));
|
|
81
|
+
} catch {
|
|
82
|
+
reject(new Error('invalid_json'));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
req.on('error', fail);
|
|
86
|
+
req.on('aborted', () => fail(new Error('aborted')));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sendJson(res, status, body) {
|
|
91
|
+
if (res.destroyed || res.writableEnded) return;
|
|
92
|
+
const text = JSON.stringify(body);
|
|
93
|
+
try {
|
|
94
|
+
res.writeHead(status, {
|
|
95
|
+
'content-type': 'application/json; charset=utf-8',
|
|
96
|
+
'content-length': Buffer.byteLength(text),
|
|
97
|
+
'cache-control': 'no-store',
|
|
98
|
+
});
|
|
99
|
+
res.end(text);
|
|
100
|
+
} catch {
|
|
101
|
+
// The request was aborted; there is no response to write.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toLine(value) {
|
|
106
|
+
const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
107
|
+
if (!Number.isInteger(number) || number < 1 || number > 10_000_000) return undefined;
|
|
108
|
+
return number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cleanSessionId(value) {
|
|
112
|
+
if (typeof value !== 'string') return undefined;
|
|
113
|
+
const trimmed = value.trim();
|
|
114
|
+
if (trimmed === '' || trimmed.length > 200) return undefined;
|
|
115
|
+
if (/[\u0000-\u001f\u007f]/u.test(trimmed)) return undefined;
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function quotePath(path) {
|
|
120
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > 4096) {
|
|
121
|
+
throw new Error('invalid_path');
|
|
122
|
+
}
|
|
123
|
+
if (/[\u0000-\u001f\u007f"]/u.test(path)) {
|
|
124
|
+
throw new Error('unsupported_path_characters');
|
|
125
|
+
}
|
|
126
|
+
return /\s/u.test(path) ? `@"${path}"` : `@${path}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatAttachmentText(item) {
|
|
130
|
+
let path = item.absolutePath;
|
|
131
|
+
if (item.kind === 'directory') {
|
|
132
|
+
path = `${path.replace(/[\\/]+$/u, '')}/`;
|
|
133
|
+
}
|
|
134
|
+
const reference = quotePath(path);
|
|
135
|
+
if (item.kind === 'selection' && item.startLine !== undefined) {
|
|
136
|
+
const suffix = item.startLine === item.endLine
|
|
137
|
+
? `line ${item.startLine}`
|
|
138
|
+
: `lines ${item.startLine}-${item.endLine}`;
|
|
139
|
+
return `${reference} (${suffix})`;
|
|
140
|
+
}
|
|
141
|
+
return reference;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeAttachment(body) {
|
|
145
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
146
|
+
throw new Error('body_must_be_object');
|
|
147
|
+
}
|
|
148
|
+
const absolutePath = body.absolutePath;
|
|
149
|
+
if (typeof absolutePath !== 'string' || absolutePath.trim() === '' || absolutePath.length > 4096) {
|
|
150
|
+
throw new Error('invalid_absolute_path');
|
|
151
|
+
}
|
|
152
|
+
if (/[\u0000-\u001f\u007f"]/u.test(absolutePath)) {
|
|
153
|
+
throw new Error('unsupported_path_characters');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const kind = body.kind === 'directory'
|
|
157
|
+
? 'directory'
|
|
158
|
+
: body.kind === 'selection' ? 'selection' : 'file';
|
|
159
|
+
|
|
160
|
+
let startLine;
|
|
161
|
+
let endLine;
|
|
162
|
+
if (kind === 'selection') {
|
|
163
|
+
startLine = toLine(body.startLine);
|
|
164
|
+
endLine = toLine(body.endLine ?? body.startLine);
|
|
165
|
+
if (startLine === undefined) throw new Error('invalid_start_line');
|
|
166
|
+
if (endLine === undefined || endLine < startLine) throw new Error('invalid_end_line');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const item = {
|
|
170
|
+
absolutePath,
|
|
171
|
+
kind,
|
|
172
|
+
...(startLine === undefined ? {} : { startLine, endLine }),
|
|
173
|
+
};
|
|
174
|
+
if (typeof body.relativePath === 'string' && body.relativePath.length <= 4096) {
|
|
175
|
+
item.relativePath = body.relativePath;
|
|
176
|
+
}
|
|
177
|
+
if (typeof body.workspaceRoot === 'string' && body.workspaceRoot.length <= 4096) {
|
|
178
|
+
item.workspaceRoot = body.workspaceRoot;
|
|
179
|
+
}
|
|
180
|
+
if (typeof body.languageId === 'string' && body.languageId.length <= 80) {
|
|
181
|
+
item.languageId = body.languageId;
|
|
182
|
+
}
|
|
183
|
+
if (typeof body.vscodeVersion === 'string' && body.vscodeVersion.length <= 40) {
|
|
184
|
+
item.vscodeVersion = body.vscodeVersion;
|
|
185
|
+
}
|
|
186
|
+
return item;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function injectBridgeConfig(html, token, channel) {
|
|
190
|
+
const value = JSON.stringify({ token, channel }).replaceAll('<', '\\u003c');
|
|
191
|
+
const script = `<script>globalThis.__DSH_VSCODE_BRIDGE__=${value};</script>`;
|
|
192
|
+
if (/<head(?:\s[^>]*)?>/iu.test(html)) {
|
|
193
|
+
return html.replace(/<head(?:\s[^>]*)?>/iu, (open) => `${open}${script}`);
|
|
194
|
+
}
|
|
195
|
+
return `${script}${html}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function apply(ctx) {
|
|
199
|
+
const token = randomBytes(24).toString('hex');
|
|
200
|
+
const channel = randomBytes(12).toString('hex');
|
|
201
|
+
const clients = new Map();
|
|
202
|
+
const queue = [];
|
|
203
|
+
const history = [];
|
|
204
|
+
const bridgeFile = bridgeFilePath();
|
|
205
|
+
|
|
206
|
+
let sequence = 0;
|
|
207
|
+
let lastClientId;
|
|
208
|
+
let lastSessionId;
|
|
209
|
+
let lastAttachAt;
|
|
210
|
+
let published = false;
|
|
211
|
+
let publishing = false;
|
|
212
|
+
let publishAttempts = 0;
|
|
213
|
+
let publishTimer;
|
|
214
|
+
let disposed = false;
|
|
215
|
+
let configFetches = 0;
|
|
216
|
+
|
|
217
|
+
const info = (...args) => {
|
|
218
|
+
try {
|
|
219
|
+
ctx.logger?.info?.('[dsh-vscode-bridge]', ...args);
|
|
220
|
+
} catch {
|
|
221
|
+
// Logging must never break the bridge.
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const warn = (...args) => {
|
|
225
|
+
try {
|
|
226
|
+
ctx.logger?.warn?.('[dsh-vscode-bridge]', ...args);
|
|
227
|
+
} catch {
|
|
228
|
+
// Logging must never break the bridge.
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const origin = () => {
|
|
232
|
+
try {
|
|
233
|
+
return ctx.webServer.getOrigin();
|
|
234
|
+
} catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const removeClient = (id) => {
|
|
240
|
+
const client = clients.get(id);
|
|
241
|
+
if (client === undefined) return;
|
|
242
|
+
clearInterval(client.heartbeat);
|
|
243
|
+
clients.delete(id);
|
|
244
|
+
try {
|
|
245
|
+
client.res.end();
|
|
246
|
+
} catch {
|
|
247
|
+
// The socket is already closed.
|
|
248
|
+
}
|
|
249
|
+
if (lastClientId === id) {
|
|
250
|
+
const remaining = [...clients.values()];
|
|
251
|
+
const last = remaining[remaining.length - 1];
|
|
252
|
+
lastClientId = last?.id;
|
|
253
|
+
lastSessionId = last?.sessionId;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const pushHistory = (item) => {
|
|
258
|
+
history.push(item);
|
|
259
|
+
while (history.length > MAX_HISTORY) history.shift();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const enqueue = (item) => {
|
|
263
|
+
queue.push(item);
|
|
264
|
+
while (queue.length > MAX_QUEUE) queue.shift();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const writeEvent = (client, item) => {
|
|
268
|
+
if (client.res.destroyed || client.res.writableEnded) return false;
|
|
269
|
+
try {
|
|
270
|
+
client.res.write(`id: ${String(item.seq)}\nevent: attachment\ndata: ${JSON.stringify(item)}\n\n`);
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const flushQueue = (client) => {
|
|
278
|
+
if (queue.length === 0) return;
|
|
279
|
+
const pending = queue.splice(0, queue.length);
|
|
280
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
281
|
+
const item = pending[index];
|
|
282
|
+
if (!writeEvent(client, item)) {
|
|
283
|
+
queue.unshift(...pending.slice(index));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
pushHistory(item);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const deliver = (item) => {
|
|
291
|
+
const candidates = [...clients.values()];
|
|
292
|
+
if (candidates.length === 0) {
|
|
293
|
+
enqueue(item);
|
|
294
|
+
return { delivered: false, queued: true };
|
|
295
|
+
}
|
|
296
|
+
const preferred = (lastClientId === undefined ? undefined : clients.get(lastClientId))
|
|
297
|
+
?? candidates[candidates.length - 1];
|
|
298
|
+
if (preferred === undefined || !writeEvent(preferred, item)) {
|
|
299
|
+
if (preferred !== undefined) removeClient(preferred.id);
|
|
300
|
+
enqueue(item);
|
|
301
|
+
return { delivered: false, queued: true };
|
|
302
|
+
}
|
|
303
|
+
pushHistory(item);
|
|
304
|
+
return { delivered: true, queued: false };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const removeBridgeFile = async () => {
|
|
308
|
+
try {
|
|
309
|
+
const text = await readFile(bridgeFile, 'utf8');
|
|
310
|
+
const current = JSON.parse(text);
|
|
311
|
+
if (current?.token === token) await rm(bridgeFile, { force: true });
|
|
312
|
+
} catch {
|
|
313
|
+
// The file may be absent, unreadable, or owned by another instance.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const publishBridgeFile = async () => {
|
|
318
|
+
if (published || publishing || disposed) return;
|
|
319
|
+
const url = origin();
|
|
320
|
+
if (url === undefined) {
|
|
321
|
+
if (publishTimer === undefined && publishAttempts < PUBLISH_MAX_ATTEMPTS) {
|
|
322
|
+
publishAttempts += 1;
|
|
323
|
+
publishTimer = setTimeout(() => {
|
|
324
|
+
publishTimer = undefined;
|
|
325
|
+
void publishBridgeFile();
|
|
326
|
+
}, PUBLISH_RETRY_MS);
|
|
327
|
+
} else if (publishAttempts >= PUBLISH_MAX_ATTEMPTS) {
|
|
328
|
+
warn('web server origin is still unavailable; token file was not written');
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
publishing = true;
|
|
333
|
+
try {
|
|
334
|
+
await mkdir(dirname(bridgeFile), { recursive: true });
|
|
335
|
+
const payload = {
|
|
336
|
+
version: 1,
|
|
337
|
+
url,
|
|
338
|
+
token,
|
|
339
|
+
channel,
|
|
340
|
+
profile: process.env.DSH_PROFILE ?? null,
|
|
341
|
+
pid: process.pid,
|
|
342
|
+
updatedAt: new Date().toISOString(),
|
|
343
|
+
};
|
|
344
|
+
await writeFile(bridgeFile, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
345
|
+
encoding: 'utf8',
|
|
346
|
+
mode: 0o600,
|
|
347
|
+
});
|
|
348
|
+
published = true;
|
|
349
|
+
info(`ready at ${url} (config: ${bridgeFile})`);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
warn(`cannot write ${bridgeFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
352
|
+
} finally {
|
|
353
|
+
publishing = false;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const handleAttach = async (req, res) => {
|
|
358
|
+
if (req.method !== 'POST') {
|
|
359
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!tokenMatches(token, bearerToken(req))) {
|
|
363
|
+
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
let body;
|
|
367
|
+
try {
|
|
368
|
+
body = await readJsonBody(req);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid_body' });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
let attachment;
|
|
374
|
+
try {
|
|
375
|
+
attachment = normalizeAttachment(body);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid_attachment' });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
sequence += 1;
|
|
382
|
+
const item = {
|
|
383
|
+
id: String(sequence),
|
|
384
|
+
seq: sequence,
|
|
385
|
+
receivedAt: new Date().toISOString(),
|
|
386
|
+
source: 'vscode',
|
|
387
|
+
...attachment,
|
|
388
|
+
};
|
|
389
|
+
item.text = formatAttachmentText(item);
|
|
390
|
+
lastAttachAt = item.receivedAt;
|
|
391
|
+
const outcome = deliver(item);
|
|
392
|
+
void publishBridgeFile();
|
|
393
|
+
info(`attachment ${item.id}: ${item.kind} ${item.absolutePath}${item.startLine === undefined ? '' : ` lines ${item.startLine}-${item.endLine}`} delivered=${String(outcome.delivered)}`);
|
|
394
|
+
sendJson(res, 200, {
|
|
395
|
+
ok: true,
|
|
396
|
+
id: item.id,
|
|
397
|
+
delivered: outcome.delivered,
|
|
398
|
+
queued: outcome.queued,
|
|
399
|
+
clients: clients.size,
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const handleEvents = (req, res) => {
|
|
404
|
+
if (req.method !== 'GET') {
|
|
405
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
let url;
|
|
409
|
+
try {
|
|
410
|
+
url = new URL(req.url ?? '/', 'http://localhost');
|
|
411
|
+
} catch {
|
|
412
|
+
sendJson(res, 400, { ok: false, error: 'invalid_url' });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (url.searchParams.get('channel') !== channel
|
|
416
|
+
|| !tokenMatches(token, url.searchParams.get('token') ?? '')) {
|
|
417
|
+
sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const sessionId = cleanSessionId(url.searchParams.get('sessionId'));
|
|
421
|
+
|
|
422
|
+
res.writeHead(200, {
|
|
423
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
424
|
+
'cache-control': 'no-cache, no-transform',
|
|
425
|
+
connection: 'keep-alive',
|
|
426
|
+
'x-accel-buffering': 'no',
|
|
427
|
+
});
|
|
428
|
+
res.write('retry: 2000\n\n');
|
|
429
|
+
|
|
430
|
+
const client = {
|
|
431
|
+
id: randomBytes(8).toString('hex'),
|
|
432
|
+
res,
|
|
433
|
+
sessionId,
|
|
434
|
+
connectedAt: Date.now(),
|
|
435
|
+
heartbeat: undefined,
|
|
436
|
+
};
|
|
437
|
+
client.heartbeat = setInterval(() => {
|
|
438
|
+
if (res.destroyed || res.writableEnded) {
|
|
439
|
+
removeClient(client.id);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
res.write(': ping\n\n');
|
|
444
|
+
} catch {
|
|
445
|
+
removeClient(client.id);
|
|
446
|
+
}
|
|
447
|
+
}, HEARTBEAT_MS);
|
|
448
|
+
client.heartbeat.unref?.();
|
|
449
|
+
|
|
450
|
+
clients.set(client.id, client);
|
|
451
|
+
lastClientId = client.id;
|
|
452
|
+
lastSessionId = sessionId;
|
|
453
|
+
info(`SSE client ${client.id} connected for session ${sessionId ?? '(unknown)'}`);
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
res.write(`event: hello\ndata: ${JSON.stringify({
|
|
457
|
+
connected: true,
|
|
458
|
+
sessionId: sessionId ?? null,
|
|
459
|
+
queued: queue.length,
|
|
460
|
+
attachments: sequence,
|
|
461
|
+
lastAttachAt: lastAttachAt ?? null,
|
|
462
|
+
})}\n\n`);
|
|
463
|
+
} catch {
|
|
464
|
+
removeClient(client.id);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const lastEventId = Number(req.headers['last-event-id'] ?? '0');
|
|
469
|
+
if (Number.isFinite(lastEventId) && lastEventId > 0) {
|
|
470
|
+
for (const item of history) {
|
|
471
|
+
if (item.seq > lastEventId) writeEvent(client, item);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
flushQueue(client);
|
|
475
|
+
|
|
476
|
+
const cleanup = () => {
|
|
477
|
+
removeClient(client.id);
|
|
478
|
+
};
|
|
479
|
+
req.on('close', cleanup);
|
|
480
|
+
req.on('aborted', cleanup);
|
|
481
|
+
res.on('close', cleanup);
|
|
482
|
+
res.on('error', cleanup);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const handleConfig = (req, res) => {
|
|
486
|
+
if (req.method !== 'GET') {
|
|
487
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const expected = origin();
|
|
491
|
+
const originHeader = req.headers.origin;
|
|
492
|
+
const referer = req.headers.referer;
|
|
493
|
+
const site = req.headers['sec-fetch-site'];
|
|
494
|
+
const sameOrigin = expected !== undefined
|
|
495
|
+
&& (originHeader === expected
|
|
496
|
+
|| (typeof referer === 'string' && (referer === expected || referer.startsWith(`${expected}/`)))
|
|
497
|
+
|| site === 'same-origin');
|
|
498
|
+
// The Desktop renderer uses the dsh-app:// scheme. Its protocol handler
|
|
499
|
+
// forwards every non-asset request to this server with the Host-issued
|
|
500
|
+
// authentication cookie, but strips Origin and Sec-Fetch-Site.
|
|
501
|
+
const desktopForwarded = typeof req.headers.cookie === 'string'
|
|
502
|
+
&& req.headers.cookie !== ''
|
|
503
|
+
&& (typeof referer === 'string' ? referer.startsWith('dsh-app://app/') : true);
|
|
504
|
+
if (!sameOrigin && !desktopForwarded) {
|
|
505
|
+
sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
configFetches += 1;
|
|
509
|
+
sendJson(res, 200, { ok: true, version: 1, token, channel });
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const handleHealth = (req, res) => {
|
|
513
|
+
if (req.method !== 'GET') {
|
|
514
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
void publishBridgeFile();
|
|
518
|
+
sendJson(res, 200, {
|
|
519
|
+
ok: true,
|
|
520
|
+
version: 1,
|
|
521
|
+
clients: clients.size,
|
|
522
|
+
queued: queue.length,
|
|
523
|
+
sessionId: lastSessionId ?? null,
|
|
524
|
+
configFetches,
|
|
525
|
+
attachments: sequence,
|
|
526
|
+
lastAttachAt: lastAttachAt ?? null,
|
|
527
|
+
});
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
ctx.effect(() => {
|
|
531
|
+
const disposers = [
|
|
532
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/attach`, handler: handleAttach }),
|
|
533
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/events`, handler: handleEvents }),
|
|
534
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/config`, handler: handleConfig }),
|
|
535
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/health`, handler: handleHealth }),
|
|
536
|
+
ctx.webServer.tapIndex((html) => injectBridgeConfig(html, token, channel)),
|
|
537
|
+
];
|
|
538
|
+
return () => {
|
|
539
|
+
disposed = true;
|
|
540
|
+
if (publishTimer !== undefined) clearTimeout(publishTimer);
|
|
541
|
+
for (const disposer of disposers) {
|
|
542
|
+
try {
|
|
543
|
+
disposer();
|
|
544
|
+
} catch {
|
|
545
|
+
// Disposal is best-effort.
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const id of [...clients.keys()]) removeClient(id);
|
|
549
|
+
queue.length = 0;
|
|
550
|
+
history.length = 0;
|
|
551
|
+
void removeBridgeFile();
|
|
552
|
+
};
|
|
553
|
+
}, 'dsh-vscode-bridge routes');
|
|
554
|
+
|
|
555
|
+
ctx.inject(['systemPrompt'], (child) => {
|
|
556
|
+
child.effect(
|
|
557
|
+
() => child.systemPrompt.section({
|
|
558
|
+
name: 'vscode-references',
|
|
559
|
+
order: 810,
|
|
560
|
+
text: EDITOR_REFERENCES_PROMPT,
|
|
561
|
+
}),
|
|
562
|
+
'dsh-vscode-bridge prompt',
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
void publishBridgeFile();
|
|
567
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-bridge-he",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Send a VS Code selection or file path into the open DSH conversation as an @path reference, without sending the message.",
|
|
6
|
+
"license": "MulanPSL-2.0",
|
|
7
|
+
"author": "HeYitao",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"dsh",
|
|
10
|
+
"dsh-plugin",
|
|
11
|
+
"deepseek",
|
|
12
|
+
"deepseek-harness",
|
|
13
|
+
"vscode",
|
|
14
|
+
"editor",
|
|
15
|
+
"context"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/heyitaowml-ctrl/dsh-vscode-bridge.git",
|
|
20
|
+
"directory": "dsh-vscode-bridge"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/heyitaowml-ctrl/dsh-vscode-bridge#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/heyitaowml-ctrl/dsh-vscode-bridge/issues"
|
|
25
|
+
},
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./index.js",
|
|
28
|
+
"./client": "./client.js",
|
|
29
|
+
"./locale/*.json": "./locale/*.json",
|
|
30
|
+
"./icon": "./icon.svg"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.js",
|
|
34
|
+
"client.js",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"locale/*.json",
|
|
37
|
+
"icon.svg",
|
|
38
|
+
"assets/icon-180.png",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"dsh": ">=0.2.1-alpha.1 <0.3.0-0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-conversation": ">=0.2.1-alpha.1 <0.3.0-0"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"platform": "web",
|
|
53
|
+
"immediately": true,
|
|
54
|
+
"inject": [
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|