dsh-vscode-bridge-he 0.0.0-stage → 1.0.4
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 +53 -3
- package/assets/icon-180.png +0 -0
- package/client.js +180 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +12 -0
- package/index.js +561 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -5
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# DSH VSCode Bridge
|
|
2
|
+
|
|
3
|
+
Adds a bridge between VS Code and the current DSH conversation.
|
|
4
|
+
|
|
5
|
+
- VS Code right-click on a selection → `DSH: 发送选中代码到 DSH 对话`
|
|
6
|
+
- VS Code right-click on a file in the Explorer → `DSH: 发送文件路径到 DSH 对话`
|
|
7
|
+
- DSH receives an `@absolute/path` reference (plus `(line N)` / `(lines A-B)` for a selection) and inserts it at the current composer caret. It does **not** auto-send the message.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
VS Code extension
|
|
13
|
+
└─ HTTP POST /dsh-vscode/attach (Bearer token)
|
|
14
|
+
└─ DSH Host plugin (this package)
|
|
15
|
+
└─ SSE /dsh-vscode/events
|
|
16
|
+
└─ DSH browser plugin
|
|
17
|
+
└─ conversation.input.left → InputActions.insertText()
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The Host half writes its endpoint and token to `~/.dsh/vscode-bridge.json` (override with `DSH_VSCODE_BRIDGE_FILE`). The VS Code extension reads that file automatically, so no manual token copying is needed. The token file is regenerated when the DSH process/plugin starts.
|
|
21
|
+
|
|
22
|
+
When no DSH conversation is open, attachments are queued in memory and flushed to the next browser session that connects.
|
|
23
|
+
|
|
24
|
+
## DSH-side install
|
|
25
|
+
|
|
26
|
+
This directory is a local DSH bundle. Install it with:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
plugin_manager install_bundle target=C:\...\dsh-vscode-bridge
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
After installation, refresh the DSH page once (the client plugin and its injected bridge config are added to the page boot). The bundle is enabled in the profile and survives restarts.
|
|
33
|
+
|
|
34
|
+
## Endpoints
|
|
35
|
+
|
|
36
|
+
| Method | Path | Auth | Purpose |
|
|
37
|
+
| --- | --- | --- | --- |
|
|
38
|
+
| POST | `/dsh-vscode/attach` | Bearer token | Queue/deliver one reference |
|
|
39
|
+
| GET | `/dsh-vscode/events` | `token` + `channel` query | SSE stream for the browser half |
|
|
40
|
+
| GET | `/dsh-vscode/config` | same-origin page (or Desktop's authenticated forwarded request) | Supplies the browser half with the current token/channel |
|
|
41
|
+
| GET | `/dsh-vscode/health` | none | Connection/queue status for the VS Code status command |
|
|
42
|
+
|
|
43
|
+
The DSH Desktop renderer runs at `dsh-app://app/` and forwards non-asset
|
|
44
|
+
requests to the Host with its authentication cookie, so the browser half uses
|
|
45
|
+
relative `/dsh-vscode/*` URLs and works both in the Desktop window and in a
|
|
46
|
+
normal browser tab.
|
|
47
|
+
|
|
48
|
+
## Notes
|
|
49
|
+
|
|
50
|
+
- Paths are sent as absolute paths so DSH does not depend on its workspace matching the VS Code workspace.
|
|
51
|
+
- The prompt section `vscode-references` teaches the model that `(lines A-B)` means an inclusive 1-based range to read.
|
|
52
|
+
- The browser half renders nothing; it only subscribes and inserts.
|
|
53
|
+
- The bridge is loopback-only by design.
|
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@local/dsh-vscode-bridge',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
|
|
6
|
+
const RETRY_MS = 500;
|
|
7
|
+
const MAX_SEEN = 200;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Invisible composer participant: it owns the SSE subscription for the
|
|
11
|
+
* current session and inserts incoming VSCode references at the live caret
|
|
12
|
+
* through the composer's InputActions. Returning null keeps the DSH UI
|
|
13
|
+
* unchanged.
|
|
14
|
+
*
|
|
15
|
+
* This is registered under `conversation.input.left` rather than
|
|
16
|
+
* `conversation.composer.dock`: the dock is rendered only when the
|
|
17
|
+
* composer variant is active, so a brand-new session (hero variant) would
|
|
18
|
+
* not subscribe until its first message and the Host could only queue the
|
|
19
|
+
* reference. The left tool slot renders in both variants and receives the
|
|
20
|
+
* same `sessionId`, `inputActions`, and `useInput` props.
|
|
21
|
+
*/
|
|
22
|
+
function VSCodeBridgeSlot(props) {
|
|
23
|
+
const sessionId = props.sessionId;
|
|
24
|
+
const inputActions = props.inputActions;
|
|
25
|
+
// The dock slot guarantees useInput; subscribing to the draft lets the
|
|
26
|
+
// insertion add a separator only when the current draft needs one.
|
|
27
|
+
const draft = props.useInput((snapshot) => snapshot.draft);
|
|
28
|
+
|
|
29
|
+
const queueRef = React.useRef([]);
|
|
30
|
+
const seenRef = React.useRef(new Set());
|
|
31
|
+
const timerRef = React.useRef(null);
|
|
32
|
+
const mountedRef = React.useRef(true);
|
|
33
|
+
const flushRef = React.useRef(() => {});
|
|
34
|
+
|
|
35
|
+
flushRef.current = () => {
|
|
36
|
+
if (!mountedRef.current) return;
|
|
37
|
+
if (timerRef.current !== null) {
|
|
38
|
+
clearTimeout(timerRef.current);
|
|
39
|
+
timerRef.current = null;
|
|
40
|
+
}
|
|
41
|
+
if (inputActions === undefined || inputActions === null
|
|
42
|
+
|| typeof inputActions.captureInsertion !== 'function'
|
|
43
|
+
|| typeof inputActions.insertText !== 'function') {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
while (queueRef.current.length > 0) {
|
|
47
|
+
const text = queueRef.current[0];
|
|
48
|
+
let inserted = false;
|
|
49
|
+
try {
|
|
50
|
+
const span = inputActions.captureInsertion();
|
|
51
|
+
const separator = typeof draft === 'string' && draft !== '' && !/\s$/u.test(draft) ? ' ' : '';
|
|
52
|
+
inserted = inputActions.insertText(`${separator}${text} `, span) === true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.warn('[dsh-vscode-bridge] failed to insert reference', error);
|
|
55
|
+
}
|
|
56
|
+
if (!inserted) break;
|
|
57
|
+
queueRef.current.shift();
|
|
58
|
+
}
|
|
59
|
+
if (queueRef.current.length > 0 && timerRef.current === null) {
|
|
60
|
+
timerRef.current = setTimeout(() => {
|
|
61
|
+
timerRef.current = null;
|
|
62
|
+
flushRef.current();
|
|
63
|
+
}, RETRY_MS);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
mountedRef.current = true;
|
|
69
|
+
flushRef.current();
|
|
70
|
+
return () => {
|
|
71
|
+
mountedRef.current = false;
|
|
72
|
+
if (timerRef.current !== null) {
|
|
73
|
+
clearTimeout(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [inputActions]);
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
if (sessionId === undefined || sessionId === null || sessionId === '') return undefined;
|
|
81
|
+
let closed = false;
|
|
82
|
+
let source;
|
|
83
|
+
let onAttachment;
|
|
84
|
+
let onHello;
|
|
85
|
+
|
|
86
|
+
const connect = async () => {
|
|
87
|
+
let bridge = typeof window === 'undefined' ? undefined : window.__DSH_VSCODE_BRIDGE__;
|
|
88
|
+
if (bridge === undefined || bridge === null || !bridge.token || !bridge.channel) {
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch('/dsh-vscode/config', {
|
|
91
|
+
cache: 'no-store',
|
|
92
|
+
credentials: 'same-origin',
|
|
93
|
+
headers: { accept: 'application/json' },
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
96
|
+
bridge = await response.json();
|
|
97
|
+
if (typeof window !== 'undefined' && bridge !== null && bridge.token && bridge.channel) {
|
|
98
|
+
window.__DSH_VSCODE_BRIDGE__ = bridge;
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.warn('[dsh-vscode-bridge] cannot load bridge config; reload DSH after installing the plugin', error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (closed || bridge === undefined || bridge === null || !bridge.token || !bridge.channel) return;
|
|
106
|
+
|
|
107
|
+
let url;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL('/dsh-vscode/events', window.location.origin);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn('[dsh-vscode-bridge] cannot build bridge URL', error);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
url.searchParams.set('sessionId', sessionId);
|
|
115
|
+
url.searchParams.set('channel', bridge.channel);
|
|
116
|
+
url.searchParams.set('token', bridge.token);
|
|
117
|
+
|
|
118
|
+
source = new EventSource(url.toString());
|
|
119
|
+
onAttachment = (event) => {
|
|
120
|
+
try {
|
|
121
|
+
const item = JSON.parse(event.data);
|
|
122
|
+
if (item === null || typeof item !== 'object' || typeof item.text !== 'string') return;
|
|
123
|
+
const id = String(item.id ?? '');
|
|
124
|
+
if (id !== '') {
|
|
125
|
+
if (seenRef.current.has(id)) return;
|
|
126
|
+
seenRef.current.add(id);
|
|
127
|
+
if (seenRef.current.size > MAX_SEEN) {
|
|
128
|
+
const oldest = seenRef.current.values().next().value;
|
|
129
|
+
if (oldest !== undefined) seenRef.current.delete(oldest);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
queueRef.current.push(item.text);
|
|
133
|
+
flushRef.current();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.warn('[dsh-vscode-bridge] invalid attachment event', error);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
onHello = (event) => {
|
|
139
|
+
try {
|
|
140
|
+
const hello = JSON.parse(event.data);
|
|
141
|
+
console.info('[dsh-vscode-bridge] connected', hello);
|
|
142
|
+
} catch {
|
|
143
|
+
// A malformed hello is not worth surfacing.
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
source.addEventListener('attachment', onAttachment);
|
|
148
|
+
source.addEventListener('hello', onHello);
|
|
149
|
+
source.addEventListener('error', () => {
|
|
150
|
+
// EventSource retries automatically; keep the queue intact.
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
void connect();
|
|
155
|
+
|
|
156
|
+
return () => {
|
|
157
|
+
closed = true;
|
|
158
|
+
if (source !== undefined) {
|
|
159
|
+
if (onAttachment !== undefined) source.removeEventListener('attachment', onAttachment);
|
|
160
|
+
if (onHello !== undefined) source.removeEventListener('hello', onHello);
|
|
161
|
+
source.close();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}, [sessionId]);
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
inject: ['slots'],
|
|
171
|
+
apply(ctx) {
|
|
172
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
173
|
+
name: 'conversation.input.left',
|
|
174
|
+
id: 'vscode-bridge',
|
|
175
|
+
order: 90,
|
|
176
|
+
}, VSCodeBridgeSlot));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
});
|
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,561 @@
|
|
|
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 published = false;
|
|
210
|
+
let publishing = false;
|
|
211
|
+
let publishAttempts = 0;
|
|
212
|
+
let publishTimer;
|
|
213
|
+
let disposed = false;
|
|
214
|
+
let configFetches = 0;
|
|
215
|
+
|
|
216
|
+
const info = (...args) => {
|
|
217
|
+
try {
|
|
218
|
+
ctx.logger?.info?.('[dsh-vscode-bridge]', ...args);
|
|
219
|
+
} catch {
|
|
220
|
+
// Logging must never break the bridge.
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const warn = (...args) => {
|
|
224
|
+
try {
|
|
225
|
+
ctx.logger?.warn?.('[dsh-vscode-bridge]', ...args);
|
|
226
|
+
} catch {
|
|
227
|
+
// Logging must never break the bridge.
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const origin = () => {
|
|
231
|
+
try {
|
|
232
|
+
return ctx.webServer.getOrigin();
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const removeClient = (id) => {
|
|
239
|
+
const client = clients.get(id);
|
|
240
|
+
if (client === undefined) return;
|
|
241
|
+
clearInterval(client.heartbeat);
|
|
242
|
+
clients.delete(id);
|
|
243
|
+
try {
|
|
244
|
+
client.res.end();
|
|
245
|
+
} catch {
|
|
246
|
+
// The socket is already closed.
|
|
247
|
+
}
|
|
248
|
+
if (lastClientId === id) {
|
|
249
|
+
const remaining = [...clients.values()];
|
|
250
|
+
const last = remaining[remaining.length - 1];
|
|
251
|
+
lastClientId = last?.id;
|
|
252
|
+
lastSessionId = last?.sessionId;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const pushHistory = (item) => {
|
|
257
|
+
history.push(item);
|
|
258
|
+
while (history.length > MAX_HISTORY) history.shift();
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const enqueue = (item) => {
|
|
262
|
+
queue.push(item);
|
|
263
|
+
while (queue.length > MAX_QUEUE) queue.shift();
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const writeEvent = (client, item) => {
|
|
267
|
+
if (client.res.destroyed || client.res.writableEnded) return false;
|
|
268
|
+
try {
|
|
269
|
+
client.res.write(`id: ${String(item.seq)}\nevent: attachment\ndata: ${JSON.stringify(item)}\n\n`);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const flushQueue = (client) => {
|
|
277
|
+
if (queue.length === 0) return;
|
|
278
|
+
const pending = queue.splice(0, queue.length);
|
|
279
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
280
|
+
const item = pending[index];
|
|
281
|
+
if (!writeEvent(client, item)) {
|
|
282
|
+
queue.unshift(...pending.slice(index));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
pushHistory(item);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const deliver = (item) => {
|
|
290
|
+
const candidates = [...clients.values()];
|
|
291
|
+
if (candidates.length === 0) {
|
|
292
|
+
enqueue(item);
|
|
293
|
+
return { delivered: false, queued: true };
|
|
294
|
+
}
|
|
295
|
+
const preferred = (lastClientId === undefined ? undefined : clients.get(lastClientId))
|
|
296
|
+
?? candidates[candidates.length - 1];
|
|
297
|
+
if (preferred === undefined || !writeEvent(preferred, item)) {
|
|
298
|
+
if (preferred !== undefined) removeClient(preferred.id);
|
|
299
|
+
enqueue(item);
|
|
300
|
+
return { delivered: false, queued: true };
|
|
301
|
+
}
|
|
302
|
+
pushHistory(item);
|
|
303
|
+
return { delivered: true, queued: false };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const removeBridgeFile = async () => {
|
|
307
|
+
try {
|
|
308
|
+
const text = await readFile(bridgeFile, 'utf8');
|
|
309
|
+
const current = JSON.parse(text);
|
|
310
|
+
if (current?.token === token) await rm(bridgeFile, { force: true });
|
|
311
|
+
} catch {
|
|
312
|
+
// The file may be absent, unreadable, or owned by another instance.
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const publishBridgeFile = async () => {
|
|
317
|
+
if (published || publishing || disposed) return;
|
|
318
|
+
const url = origin();
|
|
319
|
+
if (url === undefined) {
|
|
320
|
+
if (publishTimer === undefined && publishAttempts < PUBLISH_MAX_ATTEMPTS) {
|
|
321
|
+
publishAttempts += 1;
|
|
322
|
+
publishTimer = setTimeout(() => {
|
|
323
|
+
publishTimer = undefined;
|
|
324
|
+
void publishBridgeFile();
|
|
325
|
+
}, PUBLISH_RETRY_MS);
|
|
326
|
+
} else if (publishAttempts >= PUBLISH_MAX_ATTEMPTS) {
|
|
327
|
+
warn('web server origin is still unavailable; token file was not written');
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
publishing = true;
|
|
332
|
+
try {
|
|
333
|
+
await mkdir(dirname(bridgeFile), { recursive: true });
|
|
334
|
+
const payload = {
|
|
335
|
+
version: 1,
|
|
336
|
+
url,
|
|
337
|
+
token,
|
|
338
|
+
channel,
|
|
339
|
+
profile: process.env.DSH_PROFILE ?? null,
|
|
340
|
+
pid: process.pid,
|
|
341
|
+
updatedAt: new Date().toISOString(),
|
|
342
|
+
};
|
|
343
|
+
await writeFile(bridgeFile, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
344
|
+
encoding: 'utf8',
|
|
345
|
+
mode: 0o600,
|
|
346
|
+
});
|
|
347
|
+
published = true;
|
|
348
|
+
info(`ready at ${url} (config: ${bridgeFile})`);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
warn(`cannot write ${bridgeFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
351
|
+
} finally {
|
|
352
|
+
publishing = false;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const handleAttach = async (req, res) => {
|
|
357
|
+
if (req.method !== 'POST') {
|
|
358
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!tokenMatches(token, bearerToken(req))) {
|
|
362
|
+
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
let body;
|
|
366
|
+
try {
|
|
367
|
+
body = await readJsonBody(req);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid_body' });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let attachment;
|
|
373
|
+
try {
|
|
374
|
+
attachment = normalizeAttachment(body);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid_attachment' });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
sequence += 1;
|
|
381
|
+
const item = {
|
|
382
|
+
id: String(sequence),
|
|
383
|
+
seq: sequence,
|
|
384
|
+
receivedAt: new Date().toISOString(),
|
|
385
|
+
source: 'vscode',
|
|
386
|
+
...attachment,
|
|
387
|
+
};
|
|
388
|
+
item.text = formatAttachmentText(item);
|
|
389
|
+
const outcome = deliver(item);
|
|
390
|
+
void publishBridgeFile();
|
|
391
|
+
info(`attachment ${item.id}: ${item.kind} ${item.absolutePath}${item.startLine === undefined ? '' : ` lines ${item.startLine}-${item.endLine}`} delivered=${String(outcome.delivered)}`);
|
|
392
|
+
sendJson(res, 200, {
|
|
393
|
+
ok: true,
|
|
394
|
+
id: item.id,
|
|
395
|
+
delivered: outcome.delivered,
|
|
396
|
+
queued: outcome.queued,
|
|
397
|
+
clients: clients.size,
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const handleEvents = (req, res) => {
|
|
402
|
+
if (req.method !== 'GET') {
|
|
403
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
let url;
|
|
407
|
+
try {
|
|
408
|
+
url = new URL(req.url ?? '/', 'http://localhost');
|
|
409
|
+
} catch {
|
|
410
|
+
sendJson(res, 400, { ok: false, error: 'invalid_url' });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (url.searchParams.get('channel') !== channel
|
|
414
|
+
|| !tokenMatches(token, url.searchParams.get('token') ?? '')) {
|
|
415
|
+
sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const sessionId = cleanSessionId(url.searchParams.get('sessionId'));
|
|
419
|
+
|
|
420
|
+
res.writeHead(200, {
|
|
421
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
422
|
+
'cache-control': 'no-cache, no-transform',
|
|
423
|
+
connection: 'keep-alive',
|
|
424
|
+
'x-accel-buffering': 'no',
|
|
425
|
+
});
|
|
426
|
+
res.write('retry: 2000\n\n');
|
|
427
|
+
|
|
428
|
+
const client = {
|
|
429
|
+
id: randomBytes(8).toString('hex'),
|
|
430
|
+
res,
|
|
431
|
+
sessionId,
|
|
432
|
+
connectedAt: Date.now(),
|
|
433
|
+
heartbeat: undefined,
|
|
434
|
+
};
|
|
435
|
+
client.heartbeat = setInterval(() => {
|
|
436
|
+
if (res.destroyed || res.writableEnded) {
|
|
437
|
+
removeClient(client.id);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
res.write(': ping\n\n');
|
|
442
|
+
} catch {
|
|
443
|
+
removeClient(client.id);
|
|
444
|
+
}
|
|
445
|
+
}, HEARTBEAT_MS);
|
|
446
|
+
client.heartbeat.unref?.();
|
|
447
|
+
|
|
448
|
+
clients.set(client.id, client);
|
|
449
|
+
lastClientId = client.id;
|
|
450
|
+
lastSessionId = sessionId;
|
|
451
|
+
info(`SSE client ${client.id} connected for session ${sessionId ?? '(unknown)'}`);
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
res.write(`event: hello\ndata: ${JSON.stringify({
|
|
455
|
+
connected: true,
|
|
456
|
+
sessionId: sessionId ?? null,
|
|
457
|
+
queued: queue.length,
|
|
458
|
+
})}\n\n`);
|
|
459
|
+
} catch {
|
|
460
|
+
removeClient(client.id);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const lastEventId = Number(req.headers['last-event-id'] ?? '0');
|
|
465
|
+
if (Number.isFinite(lastEventId) && lastEventId > 0) {
|
|
466
|
+
for (const item of history) {
|
|
467
|
+
if (item.seq > lastEventId) writeEvent(client, item);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
flushQueue(client);
|
|
471
|
+
|
|
472
|
+
const cleanup = () => {
|
|
473
|
+
removeClient(client.id);
|
|
474
|
+
};
|
|
475
|
+
req.on('close', cleanup);
|
|
476
|
+
req.on('aborted', cleanup);
|
|
477
|
+
res.on('close', cleanup);
|
|
478
|
+
res.on('error', cleanup);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const handleConfig = (req, res) => {
|
|
482
|
+
if (req.method !== 'GET') {
|
|
483
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const expected = origin();
|
|
487
|
+
const originHeader = req.headers.origin;
|
|
488
|
+
const referer = req.headers.referer;
|
|
489
|
+
const site = req.headers['sec-fetch-site'];
|
|
490
|
+
const sameOrigin = expected !== undefined
|
|
491
|
+
&& (originHeader === expected
|
|
492
|
+
|| (typeof referer === 'string' && (referer === expected || referer.startsWith(`${expected}/`)))
|
|
493
|
+
|| site === 'same-origin');
|
|
494
|
+
// The Desktop renderer uses the dsh-app:// scheme. Its protocol handler
|
|
495
|
+
// forwards every non-asset request to this server with the Host-issued
|
|
496
|
+
// authentication cookie, but strips Origin and Sec-Fetch-Site.
|
|
497
|
+
const desktopForwarded = typeof req.headers.cookie === 'string'
|
|
498
|
+
&& req.headers.cookie !== ''
|
|
499
|
+
&& (typeof referer === 'string' ? referer.startsWith('dsh-app://app/') : true);
|
|
500
|
+
if (!sameOrigin && !desktopForwarded) {
|
|
501
|
+
sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
configFetches += 1;
|
|
505
|
+
sendJson(res, 200, { ok: true, version: 1, token, channel });
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const handleHealth = (req, res) => {
|
|
509
|
+
if (req.method !== 'GET') {
|
|
510
|
+
sendJson(res, 405, { ok: false, error: 'method_not_allowed' });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
void publishBridgeFile();
|
|
514
|
+
sendJson(res, 200, {
|
|
515
|
+
ok: true,
|
|
516
|
+
version: 1,
|
|
517
|
+
clients: clients.size,
|
|
518
|
+
queued: queue.length,
|
|
519
|
+
sessionId: lastSessionId ?? null,
|
|
520
|
+
configFetches,
|
|
521
|
+
});
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
ctx.effect(() => {
|
|
525
|
+
const disposers = [
|
|
526
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/attach`, handler: handleAttach }),
|
|
527
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/events`, handler: handleEvents }),
|
|
528
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/config`, handler: handleConfig }),
|
|
529
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/health`, handler: handleHealth }),
|
|
530
|
+
ctx.webServer.tapIndex((html) => injectBridgeConfig(html, token, channel)),
|
|
531
|
+
];
|
|
532
|
+
return () => {
|
|
533
|
+
disposed = true;
|
|
534
|
+
if (publishTimer !== undefined) clearTimeout(publishTimer);
|
|
535
|
+
for (const disposer of disposers) {
|
|
536
|
+
try {
|
|
537
|
+
disposer();
|
|
538
|
+
} catch {
|
|
539
|
+
// Disposal is best-effort.
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
for (const id of [...clients.keys()]) removeClient(id);
|
|
543
|
+
queue.length = 0;
|
|
544
|
+
history.length = 0;
|
|
545
|
+
void removeBridgeFile();
|
|
546
|
+
};
|
|
547
|
+
}, 'dsh-vscode-bridge routes');
|
|
548
|
+
|
|
549
|
+
ctx.inject(['systemPrompt'], (child) => {
|
|
550
|
+
child.effect(
|
|
551
|
+
() => child.systemPrompt.section({
|
|
552
|
+
name: 'vscode-references',
|
|
553
|
+
order: 810,
|
|
554
|
+
text: EDITOR_REFERENCES_PROMPT,
|
|
555
|
+
}),
|
|
556
|
+
'dsh-vscode-bridge prompt',
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
void publishBridgeFile();
|
|
561
|
+
}
|
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.4",
|
|
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
|
+
}
|