dsh-code-server-app 0.3.22 → 0.3.23
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.en.md +19 -11
- package/README.md +12 -8
- package/assets/extensions/dshcs-editor-bridge/extension.js +11 -3
- package/assets/extensions/dshcs-editor-bridge/lib/ask-panel.js +12 -2
- package/assets/extensions/dshcs-editor-bridge/package.json +2 -2
- package/assets/extensions/dshcs-editor-bridge/webview/THIRD-PARTY.md +1 -1
- package/assets/extensions/dshcs-editor-bridge/webview/src/app.jsx +11 -1
- package/assets/extensions/dshcs-editor-bridge/webview/src/approval.jsx +28 -20
- package/assets/extensions/dshcs-editor-bridge/webview/src/panel.css +63 -0
- package/assets/extensions/dshcs-editor-bridge/webview/src/thread.jsx +52 -9
- package/assets/extensions/dshcs-editor-bridge/webview/thread.css +1 -1
- package/assets/extensions/dshcs-editor-bridge/webview/thread.js +63 -61
- package/lib/bridge-approval.mjs +40 -14
- package/lib/bridge-thread.mjs +47 -18
- package/package.json +2 -2
- package/vendor/VENDOR.json +1 -1
package/lib/bridge-approval.mjs
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ## 策略(先面板、后官方;绝不自动放行)
|
|
14
14
|
* 只对**被面板 watch 的会话**注册监听(`agent.ctx.on('approval/request', …)`,agent 作用域天然只收本会话):
|
|
15
|
-
* 1. 生成 pending 记录 → 面板随 `/sync` 的 `approvals` 字段看到卡片(工具名 / 原因 /
|
|
16
|
-
* 2. 等面板决策,最多 `HOLD_MS`(
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
15
|
+
* 1. 生成 pending 记录 → 面板随 `/sync` 的 `approvals` 字段看到卡片(工具名 / 原因 / 倒计时);
|
|
16
|
+
* 2. 等面板决策,最多 `HOLD_MS`(**默认 5 分钟** —— 0.3.22 的 8 秒对人不现实:
|
|
17
|
+
* 卡片出现到用户读完、点下去,8 秒早就过了,表现就是"授权框失效"):
|
|
18
|
+
* - 面板作答 → **返回该 outcome**(settle;官方卡片不出现,人在编辑器就地处理);
|
|
19
|
+
* - **面板关掉了**(`hasPanel()` 变 false,每 `WATCH_POLL_MS` 检查一次)→ 立刻 `return next()`,
|
|
20
|
+
* 不干等到窗口结束;
|
|
21
|
+
* - 窗口到点仍没人答 → `return next()`,**原样交给官方链路**(DSH 界面照旧弹卡);
|
|
19
22
|
* 3. 永不返回自动授权:`allowed-once` 只能来自用户点击。
|
|
20
23
|
*
|
|
21
24
|
* ## 安全
|
|
@@ -25,8 +28,11 @@
|
|
|
25
28
|
* - 不接受任何自由文本 / 路径 / 命令参数 —— 这条路由**只能回答既有的问题**,不能发起动作。
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
/** 面板优先的等待窗口(毫秒)
|
|
29
|
-
export const DEFAULT_HOLD_MS =
|
|
31
|
+
/** 面板优先的等待窗口(毫秒)。5 分钟:面板就在用户眼前,时间不该成为失败原因。 */
|
|
32
|
+
export const DEFAULT_HOLD_MS = 300000;
|
|
33
|
+
|
|
34
|
+
/** 等待期间检查"面板还在不在看"的间隔(毫秒):面板一关就立刻交回官方链路,不干等。 */
|
|
35
|
+
export const WATCH_POLL_MS = 500;
|
|
30
36
|
|
|
31
37
|
/** 同时挂起的授权请求上限(超了直接交给官方,避免堆积)。 */
|
|
32
38
|
export const MAX_PENDING = 4;
|
|
@@ -96,6 +102,7 @@ export function createApprovalBoard({ holdMs = DEFAULT_HOLD_MS, maxPending = MAX
|
|
|
96
102
|
* @param {ReturnType<typeof createApprovalBoard>} deps.board
|
|
97
103
|
* @param {(message: string) => void} [deps.log]
|
|
98
104
|
* @param {number} [deps.holdMs]
|
|
105
|
+
* @param {number} [deps.pollMs] 检查"面板还在不在看"的间隔
|
|
99
106
|
* @param {() => boolean} deps.hasPanel 面板是否在看着(关掉时立刻交给官方,不做无谓等待)
|
|
100
107
|
* @returns {{intercept: (agent: object, sessionId: string) => () => void}}
|
|
101
108
|
*/
|
|
@@ -103,10 +110,35 @@ export function createApprovalInterceptor(deps) {
|
|
|
103
110
|
const board = deps.board;
|
|
104
111
|
const log = deps.log ?? (() => {});
|
|
105
112
|
const holdMs = deps.holdMs ?? DEFAULT_HOLD_MS;
|
|
113
|
+
const pollMs = deps.pollMs ?? WATCH_POLL_MS;
|
|
106
114
|
const hasPanel = deps.hasPanel ?? (() => true);
|
|
107
115
|
/** agent → disposer(每个被 watch 的 agent 一个监听器) */
|
|
108
116
|
const registered = new Map();
|
|
109
117
|
|
|
118
|
+
/** 睡一小段(不占事件循环;unref 掉,别把进程钉住)。 */
|
|
119
|
+
function sleep(ms) {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const timer = setTimeout(resolve, ms);
|
|
122
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 等面板决策:面板作答 / 面板关掉 / 窗口到点,三者谁先到算谁。
|
|
128
|
+
* @returns {Promise<string|undefined>} outcome,或 undefined = 交给官方链路
|
|
129
|
+
*/
|
|
130
|
+
async function waitForDecision(decision) {
|
|
131
|
+
const deadline = Date.now() + holdMs;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const remaining = deadline - Date.now();
|
|
134
|
+
if (remaining <= 0) return undefined;
|
|
135
|
+
const outcome = await Promise.race([decision, sleep(Math.min(pollMs, remaining))]);
|
|
136
|
+
if (outcome !== undefined) return outcome;
|
|
137
|
+
// 面板关掉了(用户关掉 webview / 换了会话)→ 立刻交回官方链路,不干等窗口结束。
|
|
138
|
+
if (!hasPanel()) return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
async function handle(request, next) {
|
|
111
143
|
try {
|
|
112
144
|
// 面板没在看:完全不拦截,保持今天的行为。
|
|
@@ -123,20 +155,14 @@ export function createApprovalInterceptor(deps) {
|
|
|
123
155
|
// 上限已满 / id 重复:不抢,交给官方。
|
|
124
156
|
return await next();
|
|
125
157
|
}
|
|
126
|
-
|
|
127
|
-
const timeout = new Promise((resolve) => {
|
|
128
|
-
timer = setTimeout(() => resolve(undefined), holdMs);
|
|
129
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
130
|
-
});
|
|
131
|
-
const outcome = await Promise.race([decision, timeout]);
|
|
132
|
-
if (timer !== null) clearTimeout(timer);
|
|
158
|
+
const outcome = await waitForDecision(decision);
|
|
133
159
|
if (typeof outcome === 'string' && PANEL_OUTCOMES.includes(outcome)) {
|
|
134
160
|
board.drop(id); // 幂等:answer() 已删过也没关系
|
|
135
161
|
log(`授权由编辑器面板决定:${request.toolName ?? 'tool'} → ${outcome}`);
|
|
136
162
|
return outcome;
|
|
137
163
|
}
|
|
138
164
|
board.drop(id);
|
|
139
|
-
log(`授权 ${request.toolName ?? 'tool'} 面板未作答(${holdMs}ms),交给 DSH 界面`);
|
|
165
|
+
log(`授权 ${request.toolName ?? 'tool'} 面板未作答(面板关闭或超过 ${holdMs}ms),交给 DSH 界面`);
|
|
140
166
|
return await next();
|
|
141
167
|
} catch (err) {
|
|
142
168
|
// 拦截器出问题绝不能把授权变成"放行":退回官方链路(它自己会 fail closed)。
|
package/lib/bridge-thread.mjs
CHANGED
|
@@ -37,16 +37,28 @@ function clip(text, limit = MAX_TEXT) {
|
|
|
37
37
|
return text.length > limit ? `${text.slice(0, limit)}…(已截断)` : text;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 从消息内容块里取**正文**与**思考**(0.3.23 起思考也进面板,默认折叠)。
|
|
42
|
+
*
|
|
43
|
+
* DSH 的内容块类型(`packages/llm/llm/src/types.ts`):`{type:'text',text}` 是给用户看的正文,
|
|
44
|
+
* `{type:'reasoning',text}` 是模型的思考过程 —— 面板以前把它整个丢掉,于是"像官方对话"
|
|
45
|
+
* 少了最显眼的那一块(官方的 Think 行默认折叠,点开才展开)。
|
|
46
|
+
*/
|
|
47
|
+
export function partsOfContent(content) {
|
|
48
|
+
if (!Array.isArray(content)) return { text: '', thinking: '' };
|
|
49
|
+
const texts = [];
|
|
50
|
+
const thinkings = [];
|
|
44
51
|
for (const block of content) {
|
|
45
|
-
if (block
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
if (block === null || typeof block !== 'object') continue;
|
|
53
|
+
if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text);
|
|
54
|
+
else if (block.type === 'reasoning' && typeof block.text === 'string') thinkings.push(block.text);
|
|
48
55
|
}
|
|
49
|
-
return clip(
|
|
56
|
+
return { text: clip(texts.join('')), thinking: clip(thinkings.join('')) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 只取正文(旧签名保留;思考不进正文)。 */
|
|
60
|
+
export function textOfContent(content) {
|
|
61
|
+
return partsOfContent(content).text;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/** 工具调用摘要:从原始 JSON 参数里抽一个最有信息量的字段(路径 / 命令首行)。 */
|
|
@@ -101,18 +113,32 @@ function push(state, entry) {
|
|
|
101
113
|
return state.entries.length - 1;
|
|
102
114
|
}
|
|
103
115
|
|
|
104
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* 助手流帧(SessionAssistantStreamFrame):正文取 `text-delta`,思考取 `reasoning-delta`。
|
|
118
|
+
*
|
|
119
|
+
* 两者都累积到**同一个**流式条目上(text / thinking 两个字段),所以面板可以在"思考中"
|
|
120
|
+
* 就先显示折叠的思考行,正文到了再往下长 —— 与 DSH 界面的观感一致。
|
|
121
|
+
*/
|
|
105
122
|
export function applyStreamFrame(state, frame) {
|
|
106
123
|
if (frame === null || typeof frame !== 'object' || frame.type !== 'chunk') return false;
|
|
107
124
|
const chunk = frame.chunk;
|
|
108
|
-
if (chunk === null || typeof chunk !== 'object' ||
|
|
125
|
+
if (chunk === null || typeof chunk !== 'object' || typeof chunk.text !== 'string') return false;
|
|
126
|
+
const isText = chunk.type === 'text-delta';
|
|
127
|
+
const isThinking = chunk.type === 'reasoning-delta';
|
|
128
|
+
if (!isText && !isThinking) return false;
|
|
109
129
|
const turn = Number.isSafeInteger(frame.turn) ? frame.turn : null;
|
|
130
|
+
const field = isText ? 'text' : 'thinking';
|
|
110
131
|
if (state.streamingIndex !== null && state.streamingTurn === turn) {
|
|
111
132
|
const current = state.entries[state.streamingIndex];
|
|
112
|
-
current
|
|
133
|
+
current[field] = clip((current[field] ?? '') + chunk.text);
|
|
113
134
|
return true;
|
|
114
135
|
}
|
|
115
|
-
state.streamingIndex = push(state, {
|
|
136
|
+
state.streamingIndex = push(state, {
|
|
137
|
+
role: 'assistant',
|
|
138
|
+
text: isText ? clip(chunk.text) : '',
|
|
139
|
+
thinking: isThinking ? clip(chunk.text) : '',
|
|
140
|
+
streaming: true,
|
|
141
|
+
});
|
|
116
142
|
state.streamingTurn = turn;
|
|
117
143
|
return true;
|
|
118
144
|
}
|
|
@@ -128,30 +154,31 @@ export function applyEvent(state, event) {
|
|
|
128
154
|
const data = event.data ?? {};
|
|
129
155
|
|
|
130
156
|
if (event.type === 'user/message') {
|
|
131
|
-
const text =
|
|
157
|
+
const text = partsOfContent(data.content).text;
|
|
132
158
|
if (text === '') return false;
|
|
133
159
|
push(state, { role: 'user', text });
|
|
134
160
|
return true;
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
if (event.type === 'assistant/message') {
|
|
138
|
-
const text =
|
|
164
|
+
const { text, thinking } = partsOfContent(data.message?.content);
|
|
139
165
|
const streamingIndex = state.streamingIndex;
|
|
140
166
|
state.streamingIndex = null;
|
|
141
167
|
state.streamingTurn = null;
|
|
142
168
|
if (streamingIndex !== null && state.entries[streamingIndex] !== undefined) {
|
|
143
169
|
// 耐久消息落地:原地替换同一轮的流式临时条目(不重复显示,顺序也不变)。
|
|
144
|
-
|
|
170
|
+
// 正文与思考都空(例如整轮只有工具调用)→ 撤掉这条临时条目。
|
|
171
|
+
if (text === '' && thinking === '') {
|
|
145
172
|
state.entries.splice(streamingIndex, 1);
|
|
146
173
|
rebuildIndex(state);
|
|
147
174
|
return true;
|
|
148
175
|
}
|
|
149
|
-
state.entries[streamingIndex] = { role: 'assistant', text };
|
|
176
|
+
state.entries[streamingIndex] = { role: 'assistant', text, thinking };
|
|
150
177
|
rebuildIndex(state);
|
|
151
178
|
return true;
|
|
152
179
|
}
|
|
153
|
-
if (text === '') return false;
|
|
154
|
-
push(state, { role: 'assistant', text });
|
|
180
|
+
if (text === '' && thinking === '') return false;
|
|
181
|
+
push(state, { role: 'assistant', text, thinking });
|
|
155
182
|
return true;
|
|
156
183
|
}
|
|
157
184
|
|
|
@@ -228,6 +255,8 @@ export function threadSnapshot(state) {
|
|
|
228
255
|
entries: state.entries.map((entry) => ({
|
|
229
256
|
role: entry.role,
|
|
230
257
|
text: entry.text ?? null,
|
|
258
|
+
// 思考过程(0.3.23):面板渲染成官方那样的折叠行,默认收起。
|
|
259
|
+
thinking: entry.thinking ?? null,
|
|
231
260
|
streaming: entry.streaming === true,
|
|
232
261
|
name: entry.name ?? null,
|
|
233
262
|
callId: entry.callId ?? null,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-code-server-app",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "VS Code (from a code-server release) inside DSH: a right-sidebar tab driven by the plugin's own launcher over the in-process VS Code server (lib/launcher.mjs). Since 0.3.0 the bundle also ships an editor bridge (assets/extensions/dshcs-editor-bridge): a read-only channel between the in-tree VS Code extension host and DSH, giving the agent what only the editor knows (unsaved buffers, language-server diagnostics, the active selection) and letting editor gestures drive the session. Since 0.3.22 the ask panel renders that session's new content with DSH's own Markdown renderer (pinned to the deployed DSH UI version) and can answer approval requests in place — the bridge's only non-read-only route (POST /approve, constrained to pending in-process requests and the outcomes allowed-once/rejected). The tab claims DSH file addresses (dsh-resource://file/**) by file type (setting claimExtensions), so the product's own produced-file chips, delivered-file previews and inline prose mentions open in the workbench. The IDE is a resident surface moved with Element.moveBefore instead of being remounted, so switching sidebar tabs no longer reloads it. Opening the tab switches the right sidebar to fullscreen by default (setting fullscreenOnOpen). Following a workspace switch is lightweight: the workbench re-navigates with the new ?folder= and the IDE process is not restarted (since 0.2.12). Requires a DSH with the right-sidebar services (sidebarRightTabs/sidebarRight, >= 0.1.5-alpha.1); older DSH versions get a single upgrade notice on the settings page and no other UI. Two serving modes: loopback port (default) or same-origin mount on DSH's own webServer (/code-server, protected by ctx.connection.requestRejection). No code-server Node layer, no argon2, no C++ toolchain.",
|
|
3
|
+
"version": "0.3.23",
|
|
4
|
+
"description": "VS Code (from a code-server release) inside DSH: a right-sidebar tab driven by the plugin's own launcher over the in-process VS Code server (lib/launcher.mjs). Since 0.3.0 the bundle also ships an editor bridge (assets/extensions/dshcs-editor-bridge): a read-only channel between the in-tree VS Code extension host and DSH, giving the agent what only the editor knows (unsaved buffers, language-server diagnostics, the active selection) and letting editor gestures drive the session. Since 0.3.22 the ask panel renders that session's new content with DSH's own Markdown renderer (pinned to the deployed DSH UI version); since 0.3.23 it is a full-width dialog with collapsible thinking rows and can answer approval requests in place — the bridge's only non-read-only route (POST /approve, constrained to pending in-process requests and the outcomes allowed-once/rejected; panel-first for 5 minutes, handed back to the official card the moment the panel closes). The tab claims DSH file addresses (dsh-resource://file/**) by file type (setting claimExtensions), so the product's own produced-file chips, delivered-file previews and inline prose mentions open in the workbench. The IDE is a resident surface moved with Element.moveBefore instead of being remounted, so switching sidebar tabs no longer reloads it. Opening the tab switches the right sidebar to fullscreen by default (setting fullscreenOnOpen). Following a workspace switch is lightweight: the workbench re-navigates with the new ?folder= and the IDE process is not restarted (since 0.2.12). Requires a DSH with the right-sidebar services (sidebarRightTabs/sidebarRight, >= 0.1.5-alpha.1); older DSH versions get a single upgrade notice on the settings page and no other UI. Two serving modes: loopback port (default) or same-origin mount on DSH's own webServer (/code-server, protected by ctx.connection.requestRejection). No code-server Node layer, no argon2, no C++ toolchain.",
|
|
5
5
|
"homepage": "https://github.com/jinsiyu/dsh-code-server-app",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/vendor/VENDOR.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"vscodeVersion": "1.137.0",
|
|
4
4
|
"productPath": "stable-b11dabdaca0d3369986975be285db92c8795cea5",
|
|
5
5
|
"layout": "vscode-only",
|
|
6
|
-
"preparedAt": "2026-09-13T08:
|
|
6
|
+
"preparedAt": "2026-09-13T08:52:39.438Z",
|
|
7
7
|
"source": "registry",
|
|
8
8
|
"node": "v24.21.0",
|
|
9
9
|
"platform": "win32",
|