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.
@@ -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`(默认 8s):
17
- * - 窗口内作答 → **返回该 outcome**(settle;官方卡片不出现,人在编辑器就地处理);
18
- * - 无人作答 / 面板没开 → `return next()`,**原样交给官方链路**(DSH 界面照旧弹卡);
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 = 8000;
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
- let timer = null;
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)。
@@ -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
- /** 从消息内容块里取正文(text 块拼接;reasoning / tool-call 不进面板)。 */
41
- export function textOfContent(content) {
42
- if (!Array.isArray(content)) return '';
43
- const parts = [];
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 !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
46
- parts.push(block.text);
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(parts.join(''));
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
- /** 助手流帧(SessionAssistantStreamFrame):只取正文 text-delta。 */
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' || chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return false;
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.text = clip(current.text + chunk.text);
133
+ current[field] = clip((current[field] ?? '') + chunk.text);
113
134
  return true;
114
135
  }
115
- state.streamingIndex = push(state, { role: 'assistant', text: clip(chunk.text), streaming: true });
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 = textOfContent(data.content);
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 = textOfContent(data.message?.content);
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
- if (text === '') {
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.22",
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",
@@ -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:28:20.966Z",
6
+ "preparedAt": "2026-09-13T08:52:39.438Z",
7
7
  "source": "registry",
8
8
  "node": "v24.21.0",
9
9
  "platform": "win32",