dsh-code-server-app 0.3.52 → 0.3.55

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 CHANGED
@@ -215,7 +215,7 @@ only the editor knows, and lets editor gestures drive the current session.
215
215
  | editor → agent | **unsaved buffers** (disk ≠ what the user sees), active file and selection, **language-server diagnostics** with `file:line`, source and code | agent tools `editor_context` / `editor_diagnostics`; plus a notice attached before writing a dirty file |
216
216
  | editor → DSH | select code → context menu **"DSH: ask about selection"** → an **ask panel** opens (carrying `file:line` and the selection); the question enters the current session as **user input**, and that session's **new content** is rendered in the panel by DSH's own Markdown renderer | extension command `dsh-code-server.askAboutSelection` (one of the **top two** editor context-menu items) + a webview panel + `POST /ask` + the `thread` field of `/sync` |
217
217
  | DSH → editor (approval) | when the agent wants to **write outside the workspace or run a command**, the approval request shows up as a card in the panel (tool, reason, countdown); "allow once" / "reject" takes effect immediately | the `approvals` field of `/sync` + `POST /approve` (the bridge's **only** non-read-only route; constraints under "Security model") |
218
- | agent → editor | the agent changed a file → a **native diff** opens; if that buffer has unsaved changes you get a warning and **no overwrite** | host watches `tools/result`, the extension polls and opens the diff |
218
+ | agent → editor | the agent changed a file → a **native diff** opens (left = the **full pre-write text**, right = what is on disk now); if that buffer has unsaved changes you get a warning and **no overwrite** | the host reads `result.value.before` (the complete pre-write text) in `tools/post-execute` into a bounded snapshot cache → the `tools/result` event carries an opaque key → the extension fetches the text and opens the diff |
219
219
 
220
220
  - The tools are only registered while the bridge is live (so the model never sees an unusable tool), and the
221
221
  system-prompt section renders only then too.
@@ -320,6 +320,10 @@ user**, so:
320
320
  edits documents, runs commands, or spawns processes. A leaked token is therefore bounded to "sees information
321
321
  that is in the editor" and **can never** become arbitrary file writes or command execution. A whitelist
322
322
  assertion in `scripts/test-bridge-routes.mjs` guards this.
323
+ `/old` (added in 0.3.55) lives under the same invariant: it only reads the bounded cache of "pre-write copies of
324
+ the last few agent writes" (≤8 entries, ≤1 MB each, ≤4 MB total, 5-minute TTL) by **opaque key**, 404s when it
325
+ is gone, takes no path argument (so it cannot read arbitrary files) and does not consume (repeat polls get the
326
+ same text).
323
327
  2. **The four constraints on `/approve`** (drop one and it becomes an arbitrary-command-execution back door):
324
328
  (a) it can only **answer** an approval request that already exists — the body is exactly `{id, outcome}`, with
325
329
  **no free text, paths, or command arguments**, so it can answer questions but never start an action;
@@ -334,7 +338,8 @@ user**, so:
334
338
  bridge would be a "did you guess the token right" oracle for a web page.
335
339
  4. **Paths are confined to the editor's current workspace folders.**
336
340
  5. **Everything is bounded**: 200 diagnostics, 500-char messages, 256 KB request bodies, a 64-entry event ring,
337
- ≤120 thread entries per session (≤8000 chars each, ≤4 watched sessions) and ≤4 pending approvals.
341
+ ≤120 thread entries per session (≤8000 chars each, ≤4 watched sessions), ≤4 pending approvals and ≤8 pre-write
342
+ snapshots (≤1 MB each, ≤4 MB total, 5-minute TTL — see `/old`).
338
343
 
339
344
  This layer stops "another local app or a browser page that got hold of the file". A malicious program running as
340
345
  the same user could read your files and the token anyway — that is outside this plugin's threat model, exactly
@@ -583,8 +588,9 @@ The regression suite (also the single list CI uses) is:
583
588
  pnpm test # runs them all: scripts/run-all-tests.mjs
584
589
  pnpm test:apply # apply() under a stub ctx
585
590
  pnpm test:claim-types # claim-type syntax and defaults
586
- pnpm test:bridge-routes # bridge route whitelist / Origin-vs-token order / token header agreement
587
- pnpm test:bridge-extension # extension-side pure logic (dirty buffers, diagnostics, diff, delivery, panel state)
591
+ pnpm test:bridge-routes # bridge route whitelist (read-only + /approve + /old) / Origin-vs-token order / token header agreement
592
+ pnpm test:edit-snapshot # pre-write snapshots: value.before from tools/post-execute, session-cwd path resolution, triple-bounded cache, /old's 400-404-200
593
+ pnpm test:bridge-extension # extension-side pure logic (dirty buffers, diagnostics, diff old-side priority, delivery, panel state)
588
594
  pnpm test:webview # panel bundle: official renderer + tokens, version match, the four /approve constraints
589
595
  pnpm test:launcher-routes # launcher HTTP surface (spawns a real process; slow)
590
596
  pnpm test:workspace-switch # switching workspaces does not restart the process
@@ -1061,6 +1067,13 @@ What remains on the plugin side:
1061
1067
  disk. What the bridge adds is a notice *before* writing a dirty file, a diff *after*, and a warning instead of
1062
1068
  an overwrite. It does not decide whether the user saves — that would mean changing the agent's read path,
1063
1069
  which is out of scope for this version.
1070
+ - **The diff's left side is the pre-write disk content (since 0.3.55).** It comes from `result.value.before` in
1071
+ `tools/post-execute` (`write`/`edit` both hand over the whole file), so a **file that is not open in the editor
1072
+ still gets a complete left side** — up to 0.3.54 the only sources were "the editor's live buffer" and "the
1073
+ extension's own cache", and when neither hit you got an empty left pane titled "no pre-change content".
1074
+ Two cases still come up empty and the tab title says so: tools whose output is a plain string
1075
+ (`str_replace_editor` — no `value` at all) and pre-write content larger than 1 MB (not cached).
1076
+ Note `value` is execution-local: it never reaches the session log, so a host restart cannot replay an old diff.
1064
1077
 
1065
1078
  - ~~No sub-path~~ **no longer true (corrected with measurements in 0.2.0)**: the workbench HTML VS Code renders references
1066
1079
  **only relative URLs** (9 references measured, 0 absolute; `serverBasePath="."`, `rootEndpoint="."`), and the client
package/README.md CHANGED
@@ -213,7 +213,7 @@ DSH 用**资源地址**命名文件,`openFile` 只负责把地址交给右侧栏
213
213
  | 编辑器 → agent | **未保存缓冲区**(磁盘内容 ≠ 用户所见)、活动文件与选区、**语言服务器诊断**(含 file:line、来源、code) | agent 工具 `editor_context` / `editor_diagnostics`;写脏文件前额外附一条提醒 |
214
214
  | 编辑器 → DSH | 选中代码 → 右键「DSH: 针对选中内容提问」→ 打开**提问面板**(带 `文件:行` 与选中内容);提问以**用户输入**进当前会话,该会话的**新内容**用 DSH 官方 markdown 渲染器显示在面板里 | 命令 `dsh-code-server.askAboutSelection`(编辑器右键菜单**最上面两条**之一)+ webview 面板 + `POST /ask` + `/sync` 的 `thread` 字段 |
215
215
  | DSH → 编辑器(授权) | agent 要**写工作区外的文件 / 执行命令**时的授权请求 → 面板里就地弹卡片(工具名 + 原因 + 倒计时),点「允许一次 / 拒绝」立刻生效 | `/sync` 的 `approvals` 字段 + `POST /approve`(桥里**唯一**的非只读路由,约束见「安全模型」) |
216
- | agent → 编辑器 | agent 改了哪个文件 → 开**原生 diff** 审阅;缓冲区有未保存改动时**告警而不覆盖** | host 观察 `tools/result`,扩展轮询后开 diff + 非模态告警 |
216
+ | agent → 编辑器 | agent 改了哪个文件 → 开**原生 diff** 审阅(左 = **写前的完整原文**,右 = 磁盘现状);缓冲区有未保存改动时**告警而不覆盖** | host 在 `tools/post-execute` 取 `result.value.before`(完整写前全文)存入有界快照缓存 → `tools/result` 的事件带不透明 key → 扩展轮询后取回原文并开 diff + 非模态告警 |
217
217
 
218
218
  - 工具只在桥就绪时注册(IDE 没起来时模型看不到"有个用不了的工具");提示词段落也只在桥存活时渲染。
219
219
  - **提问面板**(扩展 0.2.0 起;0.2.3 起正文走官方渲染器;0.2.5 起是**浮在 DSH 界面上的对话框**):
@@ -303,6 +303,9 @@ host 反向请求不到它。所以编辑器状态只能在扩展主动发起的
303
303
  1. **`/code-server-bridge/*` 只读,只有 `/approve` 一个例外。** 没有写文件、改文档、执行命令、
304
304
  拉起进程的路由。令牌泄露的爆炸半径被封在"看到编辑器里的信息",**不会**变成任意文件写/任意命令执行。
305
305
  `scripts/test-bridge-routes.mjs` 里有一条白名单断言盯着这件事(未知后缀一律 404)。
306
+ `/old`(0.3.55 新增)也在这条不变量里:它只能按**不透明 key** 读"最近几次 agent 写操作的写前副本"
307
+ 这一份有界缓存(条数 ≤8 / 单份 ≤1MB / 总量 ≤4MB / 5 分钟过期),取不到就 404;
308
+ 它不接受路径参数,所以读不到任意文件,也不消费(重复轮询拿到同一份)。
306
309
  2. **`/approve` 的四条约束(缺一条就等于开了任意命令执行的后门,不许放宽)**:
307
310
  (a) 只能**回答**已经存在的授权请求,请求体只有 `{id, outcome}`,**不接受任何自由文本 / 路径 / 命令参数**
308
311
  —— 它只能"回答问题",不能"发起动作";(b) `id` 必须是本进程自己发起、且**仍未决**的请求(用后即废);
@@ -318,7 +321,8 @@ host 反向请求不到它。所以编辑器状态只能在扩展主动发起的
318
321
  会让这道 403 静默失效(测试里有这条实测记录)。
319
322
  4. **路径收敛在编辑器当前工作区**(`workspaceFolder` 之外的诊断直接丢弃)。
320
323
  5. **有界**:诊断默认 200 条 / 单条截断 500 字符 / 上报体上限 256KB / 事件环形缓冲 64 条 /
321
- 对话流每会话 ≤120 条(单条正文 ≤8000 字符、同时 watch ≤4 个会话)/ 待决授权 ≤4 条。
324
+ 对话流每会话 ≤120 条(单条正文 ≤8000 字符、同时 watch ≤4 个会话)/ 待决授权 ≤4 条 /
325
+ 写前原文快照 ≤8 份、单份 ≤1MB、总量 ≤4MB、5 分钟过期(见 `/old`)。
322
326
 
323
327
  这一层挡的是"本机其它应用或浏览器页面拿到那个文件后乱调桥";**同用户的本地恶意程序**
324
328
  本来就能直接读你的文件与令牌文件 —— 那不在本插件的威胁模型内(与「回环端口的安全模型」同一句话)。
@@ -470,8 +474,9 @@ pnpm test # 一次跑完下面全部(scripts/run-all-tests.mj
470
474
  # ↑ 是唯一清单:新增回归脚本只改 scripts/run-all-tests.mjs,CI/README 都跟着它走
471
475
  pnpm test:apply # 桩 ctx 下跑通 apply(回归:apply 期的 ReferenceError)
472
476
  pnpm test:claim-types # 认领类型语法与默认值
473
- pnpm test:bridge-routes # 编辑器桥:路由表白名单(只读 + /approve)/ Origin 与令牌的判定顺序 / 令牌头三处一致
474
- pnpm test:bridge-extension # 编辑器桥扩展侧纯逻辑:未保存缓冲区上报、诊断排序截断、diff 判据、投递降级、面板状态机
477
+ pnpm test:bridge-routes # 编辑器桥:路由表白名单(只读 + /approve + /old)/ Origin 与令牌的判定顺序 / 令牌头三处一致
478
+ pnpm test:edit-snapshot # 写前原文快照:从 tools/post-execute 的 value 取完整 before / 路径按会话 cwd 绝对化 / 缓存三重有界 / /old 的 400-404-200
479
+ pnpm test:bridge-extension # 编辑器桥扩展侧纯逻辑:未保存缓冲区上报、诊断排序截断、diff 判据(old 侧优先级)、投递降级、面板状态机
475
480
  pnpm test:webview # 面板 webview 产物:官方渲染器与令牌打包、版本一致、/approve 的四条约束(先跑 build:webview)
476
481
  pnpm test:launcher-routes # launcher 的 HTTP 面(起真进程,较慢)
477
482
  pnpm test:workspace-switch # 切工作区不重启进程
@@ -481,6 +486,8 @@ pnpm test:client-seat # 设置卡住哪个座位:插件页 plugins.bundle
481
486
  pnpm test:fullscreen # 打开标签即全屏
482
487
  pnpm test:vendored # 重打包表 ↔ 插件依赖表一致(无 npm: 别名 / 无聚合包 / vendored.json 进了 files)
483
488
  pnpm test:dsh-resolve # 部署位置表:各平台全局装布局(npm --prefix / nvm / pnpm global / %APPDATA%)都能找到 DSH 部署
489
+ pnpm test:child-node # Electron 宿主(桌面版)下给 IDE 子进程挑真 Node:候选顺序 / 剥离 ELECTRON_RUN_AS_NODE / 找不到时如实退回
490
+ pnpm test:package-files # 发布物白名单守卫:files 里的模块**递归**import 到的本地文件也必须在 files 里(0.3.53 漏 lib/child-node.mjs 的事故)
484
491
  pnpm test:installed # 安装冒烟:对**已装进 profile 的产物**做断言(默认 <DSH_HOME>/profiles/web)
485
492
  # files 白名单每条都在 / 重打包包在当前平台齐全 / 原生模块无缺失 /
486
493
  # 已安装副本能 import / 树在位 —— 仓库回归看不出这一类
@@ -962,6 +969,21 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
962
969
 
963
970
  ## DSH Desktop(无 webServer)
964
971
 
972
+ - **IDE 子进程必须用真 Node 跑(0.3.53 起;桌面版特有的硬约束)**:DSH Desktop 的宿主进程是
973
+ **Electron 的 Node 模式**(`ELECTRON_RUN_AS_NODE=1`,`process.execPath` = Electron 二进制)。VS Code 的
974
+ `server-main.js` 会注册一段**只在 Electron / `ELECTRON_RUN_AS_NODE` 下生效**的 asar 解析钩子:凡是解析结果
975
+ 落在"应用根"之外的包,它就去 `node_modules.asar` 里找,找不到直接抛
976
+ `Cannot find package 'X' within the application resources`。本插件的原生包都是**树外**的 pnpm 目录,
977
+ 所以启动路径上的 `@vscode/spdlog` / `@vscode/deviceid` / `@vscode/windows-registry` 三个 ESM import 必被拒
978
+ ⇒ launcher 打 `FATAL 加载 VS Code server 失败` ⇒ 面板显示「**code-server 意外退出(exit 2)**」(界面上的
979
+ 报错只有栈尾,首行被日志尾部截断,所以看不出是哪个包)。修法:`lib/child-node.mjs` 检测到 Electron 宿主时,
980
+ 改用**应用自带的真 Node**(`resources/runtime/primary-runtime/dependencies/node/bin/node.exe`,实测 v24.21.0),
981
+ 并把 `ELECTRON_RUN_AS_NODE` 从子进程环境里剥掉;web/CLI 的普通 node 宿主行为一字不变。
982
+ - 试过但不能用的兜底:`VSCODE_DEV=1`(绕过了钩子,却切到 dev 引导路径 → 报 `<树>/lib/vscode/out/bootstrap-import.js` 缺失);
983
+ 往树里补 junction(Node 的 ESM 解析会 realpath,链接建在树里也仍被判为"应用根之外")。
984
+ - 回归:`pnpm test:child-node`(9 项)。
985
+ - **与端口冲突的区别**:端口冲突的签名是 `FATAL 监听失败({...}): listen EADDRINUSE`(发生在**加载成功之后**
986
+ 绑端口阶段),而上面这个是加载阶段就死 —— 用随机端口(随机端口也会炸)即可区分。
965
987
  - host 半部 `inject = ['connection', 'settings']`(**不含 `webServer`**)——desktop profile 关掉了 webserver/web-runtime,
966
988
  本插件照常工作;`/api/*` 请求由 Electron `dsh-app://` 协议处理器 → IPC 帧管道 → `createSharedFetchHandler('/api')`。
967
989
  - 右侧栏标签、guide 入口框、文件地址认领、设置卡片在 desktop 下与 web 相同(code-server 仍是本机 `http://127.0.0.1:<port>` 的 iframe;
@@ -1023,6 +1045,11 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
1023
1045
  - **未保存缓冲区是"上报"而不是"接管"**:agent 仍然通过它自己的 `fs` 工具按磁盘内容编辑。
1024
1046
  桥能做的是**在写之前提醒**、**写之后给 diff**、**冲突时告警而不覆盖** ——
1025
1047
  它不能替用户决定保存与否(那需要改动 agent 的读路径,不在本版本范围内)。
1048
+ - **diff 的左栏是"写前的磁盘内容"(0.3.55 起)**:值取自 `tools/post-execute` 的 `result.value.before`
1049
+ (`write`/`edit` 都给整份文件文本),所以**文件没在编辑器里打开也能给出完整左栏** ——
1050
+ 0.3.54 及以前只有"编辑器缓冲区 / 扩展自己的缓存"两条来源,都没命中时左栏是空文本 + 标题写"没有改动前的内容"。
1051
+ 仍然拿不到的情形有两种,标题会如实说明:`str_replace_editor` 这类 output 是纯字符串的工具(没有 `value`),
1052
+ 以及写前内容 >1MB(不塞进缓存)。**注意** `value` 是 execution-local:它不进会话日志,宿主重启后旧的 diff 不会重放。
1026
1053
  - **提问面板只渲染"新内容"(0.3.22)**:订阅从面板建立那一刻开始,`follow` 开帧里的历史 `records` 被丢弃,
1027
1054
  面板里**没有"加载更早"**(历史分页 API `sessionController.page()` 在这个版本里刻意不调用)。
1028
1055
  想看更早的内容请回 DSH 界面。
@@ -36,7 +36,7 @@ const {
36
36
  createClient,
37
37
  } = require('./lib/bridge-client.js');
38
38
  const { createProjector } = require('./lib/context-model.js');
39
- const { createDiffCache, describeChange } = require('./lib/diff-model.js');
39
+ const { chooseOldSide, createDiffCache, describeChange } = require('./lib/diff-model.js');
40
40
  const {
41
41
  applySync,
42
42
  createPanelState,
@@ -222,8 +222,12 @@ function activeSnapshot() {
222
222
  /**
223
223
  * agent 改了一个文件:给出 old/new 两侧并开 diff。
224
224
  *
225
- * 时序很关键:事件到达时 VS Code 的磁盘 watcher 可能还没把新内容灌进缓冲区,
226
- * 所以**先同步取缓冲区文本**(= 改动前),再去读磁盘(= 改动后)。
225
+ * 时序很关键,别随手调换顺序:
226
+ * ① **先同步取缓冲区文本**(此刻 VS Code 的磁盘 watcher 可能还没把新内容灌进缓冲区 ⇒
227
+ * 晚一步就取不到"改动前"了)与 isDirty;
228
+ * ② 再去宿主取**写前原文快照**(事件里的 oldKey;宿主在写的那一刻抓的,唯一精确的来源);
229
+ * ③ old 侧按 chooseOldSide 的优先级选,④ 最后才读磁盘作 new 侧。
230
+ * ②③ 都要 await,所以①必须在它们之前 —— 否则"文件开着但宿主没给快照"的那条路就废了。
227
231
  */
228
232
  async function handleAgentEdit(event) {
229
233
  const target = typeof event.path === 'string' && event.path !== '' ? event.path : null;
@@ -234,20 +238,40 @@ async function handleAgentEdit(event) {
234
238
  }
235
239
  const uri = vscode.Uri.file(target);
236
240
 
237
- // 1) old 侧:优先"此刻的缓冲区"(还未被磁盘改动刷新);没有打开的文档就退回缓存。
238
- let oldText = diffCache.recall(target);
241
+ // 1) 先无条件把"此刻的缓冲区"抓在手里(它只在拿不到快照时才被采用,但必须现在取)。
242
+ let bufferText = null;
239
243
  let dirty = false;
240
244
  const open = vscode.workspace.textDocuments.find((doc) => documentId(doc) === target);
241
245
  if (open !== undefined) {
242
246
  try {
243
- oldText = open.getText();
247
+ bufferText = open.getText();
244
248
  } catch {
245
- // 保留缓存值
249
+ bufferText = null;
246
250
  }
247
251
  dirty = open.isDirty === true;
248
252
  }
249
253
 
250
- // 2) new 侧:磁盘内容。
254
+ // 2) 宿主侧的写前原文(0.3.55):文件没在编辑器里打开时,这是唯一的 old 侧来源。
255
+ let snapshotText = null;
256
+ if (typeof event.oldKey === 'string' && event.oldKey !== '' && client !== null && typeof client.oldText === 'function') {
257
+ try {
258
+ snapshotText = await client.oldText(event.oldKey);
259
+ } catch (error) {
260
+ log(`取写前原文失败(${target}):${error && error.message ? error.message : error}`);
261
+ snapshotText = null;
262
+ }
263
+ }
264
+
265
+ // 3) old 侧:新建 ⇒ 空;其次快照;再退缓冲区;再退上次见过的缓存。
266
+ const chosen = chooseOldSide({
267
+ snapshotText,
268
+ bufferText,
269
+ cachedText: diffCache.recall(target),
270
+ operation: typeof event.operation === 'string' ? event.operation : null,
271
+ });
272
+ const oldText = chosen.text;
273
+
274
+ // 4) new 侧:磁盘内容。
251
275
  let newText = null;
252
276
  try {
253
277
  const bytes = await vscode.workspace.fs.readFile(uri);
@@ -257,13 +281,17 @@ async function handleAgentEdit(event) {
257
281
  }
258
282
  diffCache.remember(target, newText === null ? '' : newText, Date.now());
259
283
 
260
- const decision = describeChange(oldText, newText);
284
+ const decision = describeChange(oldText, newText, {
285
+ operation: typeof event.operation === 'string' ? event.operation : null,
286
+ oldSide: typeof event.oldSide === 'string' ? event.oldSide : null,
287
+ });
261
288
  if (decision.show === false) {
262
289
  log(`agent 改动 ${target}:${decision.reason} → 不打扰`);
263
290
  return;
264
291
  }
292
+ log(`agent 改动 ${target}:old=${chosen.source}(${oldText === null ? '无' : `${oldText.length} 字符`}) ${decision.reason}`);
265
293
 
266
- // 3) 开 diff(**左 = 改动前的虚拟文档,右 = 真实的磁盘文件**)。
294
+ // 5) 开 diff(**左 = 改动前的虚拟文档,右 = 真实的磁盘文件**)。
267
295
  // 右栏刻意用 file: URI:这样用户在 diff 里按"撤销/编辑"落到的是真文件,VS Code 的
268
296
  // 常规编辑与撤销栈全部生效,我们不需要自己做任何写回。
269
297
  const docId = registerDiffText(oldText ?? '');
@@ -310,7 +338,7 @@ async function handleAgentEdit(event) {
310
338
  log(`打开 diff 失败(${target}):${error && error.message ? error.message : error}`);
311
339
  }
312
340
 
313
- // 4) 脏缓冲区:只告警,绝不覆盖。
341
+ // 6) 脏缓冲区:只告警,绝不覆盖。
314
342
  if (dirty === true) {
315
343
  const choice = await vscode.window.showWarningMessage(
316
344
  `${path.basename(target)} 在编辑器里有未保存的改动,而 DSH 刚改了磁盘上的同名文件。`,
@@ -334,6 +334,26 @@ function createClient(options) {
334
334
  body: JSON.stringify({ id, outcome }),
335
335
  });
336
336
  },
337
+ /**
338
+ * 取一份"写前原文"快照(0.3.55):事件里带的是不透明 key,文本单独取。
339
+ *
340
+ * 为什么不让事件直接带文本:host 每 600ms 的 `/sync` 响应还驮着对话流与待决授权,
341
+ * 塞进 ~1MB 文本会把那一趟拖成超时(超时 ⇒ approvals 一起丢 ⇒ 授权卡片永远不出现)。
342
+ *
343
+ * 取不到(404 = 过期/被淘汰/宿主重启)时**返回 null 而不是抛**:调用方据此回退到缓冲区或缓存。
344
+ *
345
+ * @param {string} key 事件里的 oldKey
346
+ * @returns {Promise<string|null>} 写前原文;没有就给 null
347
+ */
348
+ async oldText(key) {
349
+ if (typeof key !== 'string' || key === '') return null;
350
+ try {
351
+ const body = await request(`${BRIDGE_BASE}/old?key=${encodeURIComponent(key)}`);
352
+ return body !== null && typeof body.text === 'string' ? body.text : null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ },
337
357
  /** 把游标落盘(实例重启后不重复播报旧事件)。 */
338
358
  persist() {
339
359
  return writeState(extensionsDir, { since, pid: config === null ? null : config.pid });
@@ -4,23 +4,52 @@
4
4
  //
5
5
  // 端到端这条链路是这样走的(每一步都有理由,别随手简化):
6
6
  //
7
- // host: `ctx.on('tools/result')` 看到写类工具(exec.name + exec.arguments)
8
- // → 推一条 {kind:'agent-edit', path} 进环形缓冲(只播"去看一眼",不播数据)
7
+ // host: `ctx.on('tools/post-execute')` 拿到这次写的**完整写前原文**(`result.value.before`)
8
+ // → 存进有界快照缓存,`tools/result` 的事件里只带一个不透明 key(见 host 侧 lib/edit-snapshot.mjs)
9
9
  // ext : 轮询拿到事件
10
- // → **立刻**取该文档当前文本作 old 侧:此时 VS Code 的磁盘 watcher 还没把新内容灌进缓冲区,
11
- // 所以缓冲区里拿到的正是"改动前"的内容(时序敏感,不能先 await 别的)
10
+ // → old 侧按优先级取:`operation==='create'`(左栏本来就该空)→ **快照**(写前磁盘内容,
11
+ // 唯一精确的来源)→ 该文档此刻的缓冲区(**必须在 await 别的之前同步取**:此刻 VS Code 的
12
+ // 磁盘 watcher 可能还没把新内容灌进缓冲区,晚一步就拿不到"改动前"了)→ 上次见过的缓存
12
13
  // → 再用 workspace.fs 读磁盘作 new 侧
13
14
  // → old === new ⇒ 不打扰用户(agent 写的和盘上一样,缓冲区本来就没差别)
14
15
  // → 不同 ⇒ 开 diff tab;若该文档 isDirty,弹一条非模态告警(绝不自动覆盖用户的未保存改动)
15
16
  //
16
- // 为什么要缓存"上次见过的内容":文件没在编辑器里打开时缓冲区取不到 old 侧,只能靠上次轮询时
17
- // 记下的内容。缓存有界(超出丢最旧),因为它的用途只是 diff,不是版本控制。
17
+ // 为什么快照优先于缓冲区(0.3.55 之前是反过来的):文件**没在编辑器里打开**时缓冲区根本不存在,
18
+ // 那时左栏只能给空文本 + "没有改动前的内容" —— 用户实测到的就是这个(空 old / 全文 new)。
19
+ // 缓冲区是"用户此刻看到的东西",不是"DSH 改之前磁盘上的东西";只有拿不到快照时才用它兜底,
20
+ // 而它偏向哪一侧(改前/改后)取决于磁盘 watcher 有没有抢在前面刷新。
21
+ //
22
+ // 为什么要缓存"上次见过的内容":文件没在编辑器里打开、宿主也没给快照(旧版 DSH / 工具没给 value)
23
+ // 时,只能靠上次轮询时记下的内容。缓存有界(超出丢最旧),因为它的用途只是 diff,不是版本控制。
18
24
 
19
25
  'use strict';
20
26
 
21
27
  /** 缓存条目上限(每个条目是一份完整文件文本 —— 不能无界)。 */
22
28
  const CACHE_MAX = 64;
23
29
 
30
+ /**
31
+ * 选 old 侧文本(改动前的左侧),并说明它来自哪里。
32
+ *
33
+ * 优先级(每条都有具体理由,见文件头):
34
+ * 1. `operation === 'create'` —— 宿主明确说是新建 ⇒ 左栏就该是空文本(而不是"拿不到")。
35
+ * 2. 快照文本 —— 宿主在**写的那一刻**抓的写前原文,唯一精确的来源。
36
+ * 3. 缓冲区文本 —— 文档开着才有的兜底;可能是改后(磁盘 watcher 已刷新)或用户的未保存改动。
37
+ * 4. 上次见过的缓存 —— 老行为,聊胜于无。
38
+ * 5. 都没有 ⇒ null(调用方据此显示"拿不到改动前的内容",而不是假装文件原来是空的)。
39
+ *
40
+ * @param {{snapshotText?: string|null, bufferText?: string|null, cachedText?: string|null,
41
+ * operation?: string|null}} input
42
+ * @returns {{text: string|null, source: 'create'|'snapshot'|'buffer'|'cache'|'none'}}
43
+ */
44
+ function chooseOldSide(input) {
45
+ const value = input === null || input === undefined ? {} : input;
46
+ if (value.operation === 'create') return { text: '', source: 'create' };
47
+ if (typeof value.snapshotText === 'string') return { text: value.snapshotText, source: 'snapshot' };
48
+ if (typeof value.bufferText === 'string') return { text: value.bufferText, source: 'buffer' };
49
+ if (typeof value.cachedText === 'string') return { text: value.cachedText, source: 'cache' };
50
+ return { text: null, source: 'none' };
51
+ }
52
+
24
53
  /**
25
54
  * 判据:这次改动值不值得开 diff。
26
55
  *
@@ -29,16 +58,35 @@ const CACHE_MAX = 64;
29
58
  * `added` / `removed` 在**只有一侧**时是 `null` 而不是猜出来的数字:没有 old 侧就无从知道
30
59
  * "新增了几行"(新文件的所有行都是新增,但那只对新建文件成立;覆盖写不是)——
31
60
  * 报个 0 或报个全文行数都会误导用户与模型。null 让调用方只显示"有变化"。
61
+ * **例外**:宿主明确说了 `operation === 'create'`(真·新建)时行数是确定的(全文都是新增),
62
+ * 报 `+N` 不是猜的。
32
63
  *
33
- * @param {string|null} oldText 改动前(缓冲区/缓存)
64
+ * @param {string|null} oldText 改动前(快照/缓冲区/缓存)
34
65
  * @param {string|null} newText 改动后(磁盘)
66
+ * @param {{operation?: string|null, oldSide?: string|null}} [options]
67
+ * `operation` 来自宿主的 FsWriteOutcome('create' | 'update');
68
+ * `oldSide` 是宿主对"为什么没有 old 文本"的说明('too-large' | 'unavailable')。
35
69
  * @returns {{show: boolean, reason: string, added: number|null, removed: number|null}}
36
70
  */
37
- function describeChange(oldText, newText) {
71
+ function describeChange(oldText, newText, options) {
38
72
  const oldStr = typeof oldText === 'string' ? oldText : null;
39
73
  const newStr = typeof newText === 'string' ? newText : null;
74
+ const opts = options === null || options === undefined ? {} : options;
75
+ if (opts.operation === 'create') {
76
+ if (newStr === null) return { show: false, reason: '新建的文件已不可读', added: null, removed: null };
77
+ if (newStr === '') return { show: false, reason: '新建了空文件', added: 0, removed: 0 };
78
+ // 用"diff 意义上的行数":末尾那个换行不算一整行(否则 "a\nb\nc\n" 会报 +4,而 diff 里只显示 3 行)
79
+ return { show: true, reason: '新建文件', added: contentLineCount(newStr), removed: 0 };
80
+ }
40
81
  if (oldStr === null && newStr === null) return { show: false, reason: '两侧都不可读', added: null, removed: null };
41
- if (oldStr === null) return { show: newStr !== '', reason: '没有改动前的内容', added: null, removed: null };
82
+ if (oldStr === null) {
83
+ return {
84
+ show: newStr !== '',
85
+ reason: opts.oldSide === 'too-large' ? '改动前的内容过大,未取到' : '拿不到改动前的内容',
86
+ added: null,
87
+ removed: null,
88
+ };
89
+ }
42
90
  if (newStr === null) return { show: oldStr !== '', reason: '文件已被删除', added: null, removed: null };
43
91
  if (oldStr === newStr) return { show: false, reason: '内容未变化', added: 0, removed: 0 };
44
92
  const stats = lineStats(oldStr, newStr);
@@ -51,6 +99,14 @@ function countLines(text) {
51
99
  return text.split(/\r\n|\r|\n/).length;
52
100
  }
53
101
 
102
+ /** diff 意义上的行数:末尾的换行符不算多出一行(VS Code 的 diff 也是这么显示的)。 */
103
+ function contentLineCount(text) {
104
+ if (typeof text !== 'string' || text === '') return 0;
105
+ const lines = text.split(/\r\n|\r|\n/);
106
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
107
+ return lines.length;
108
+ }
109
+
54
110
  /**
55
111
  * 极简行级统计(不是完整 diff —— 只用来给用户一句"±N 行"的量级,完整 diff 由 VS Code 渲染)。
56
112
  * 用"最长公共前后缀裁剪"来近似:裁剪后剩下的行数就是改动规模。
@@ -116,6 +172,7 @@ function createDiffCache(max) {
116
172
 
117
173
  module.exports = {
118
174
  CACHE_MAX,
175
+ chooseOldSide,
119
176
  describeChange,
120
177
  countLines,
121
178
  lineStats,
@@ -2,7 +2,7 @@
2
2
  "name": "dshcs-editor-bridge",
3
3
  "displayName": "DSH Editor Bridge",
4
4
  "description": "只读地把编辑器状态(未保存缓冲区、诊断、活动选区)提供给 DSH;右键提问打开 DSH 页面里的悬浮对话对话框(0.2.5;老宿主退回编辑器面板),正文/思考/上下文都用 DSH 官方渲染器,并可就地回答授权请求。由 dsh-code-server-app 插件安装,可禁用。",
5
- "version": "0.2.15",
5
+ "version": "0.2.16",
6
6
  "publisher": "dsh-code-server-app",
7
7
  "license": "MIT",
8
8
  "private": true,
@@ -12,7 +12,7 @@
12
12
  | react | 18.3.1 | MIT |
13
13
  | react-dom | 18.3.1 | MIT |
14
14
 
15
- 生成时间:2026-09-18T13:40:53.909Z
15
+ 生成时间:2026-09-19T09:11:10.701Z
16
16
  渲染器:@deepseek-ai/dsh-client-ui-primitives@0.1.6-alpha.1(DSH 部署的界面版本:未知)
17
17
  设计令牌:@deepseek-ai/dsh-client-ui-theme@0.1.6-alpha.1(11 段)
18
18
  KaTeX:已打包(公式排版与 DSH 界面一致)
@@ -1,31 +1,42 @@
1
1
  /**
2
2
  * lib/bridge-observe.mjs — 观察 agent 的写操作,并把结果送进编辑器(0.3.0)。
3
3
  *
4
- * 两个方向:
4
+ * 三个观察点,各司其职:
5
5
  *
6
- * **agent → 编辑器**(`tools/result`):把所有写类工具的落点推成"去看一眼这个文件"的事件。
6
+ * **① agent → 编辑器**(`tools/result`):把所有写类工具的落点推成"去看一眼这个文件"的事件。
7
7
  * - 不限定 agent:`ctx.on('tools/result')` 注册在根上下文,而 `dsh-scope` 的载体过滤是
8
8
  * `tag === undefined → true`,所以**一个监听器能看到所有 agent 与子 agent 的调用**;
9
- * - 事件里**只带路径**,不带内容 —— 内容由扩展自己算(它才知道缓冲区里那一份),
10
- * 这也让 host 侧不持有文件内容(少一处泄密面);
11
9
  * - 有两条抽取路径,都必要:
12
10
  * ① `result.meta.diffs`(`dsh-tool-fs` 的 write/edit 会带上 `FsDiffMeta`);
13
11
  * ② 从 `exec.arguments.file_path` / `.path` 直接取(`str_replace_editor` **不带** meta,
14
- * 在别的 profile 里它才是主力工具)。
12
+ * 在别的 profile 里它才是主力工具);
13
+ * - 路径一律按**会话 cwd** 绝对化(见下):相对路径喂给编辑器只会被当成"工作区外"丢掉,
14
+ * 或者在 IDE 自己的 cwd 上匹配到另一个文件。
15
15
  * - `tools/result` 是 emit 型观察点:抛错不会影响调用结果(`notifyResult` 内部吞掉 listener
16
16
  * 异常),所以这里出问题最坏就是"少一次 diff 提示",绝不会弄坏 agent 的一轮。
17
17
  *
18
- * **编辑器 → agent**(`tools/pre-execute`):写之前如果该文件在编辑器里是脏的,附一条提示。
18
+ * **② 写前原文**(`tools/post-execute`,0.3.55):`write`/`edit` 的 `result.value` 里有**完整**的
19
+ * 写前/写后全文,而 `tools/result` 的 durable 投影**故意**剥掉了 `value` —— 这就是"diff 左侧空、
20
+ * 右侧全文"的根因(文件没在编辑器里打开时,扩展侧没有任何 old 侧来源)。这里把写前原文存进
21
+ * 有界快照缓存(见 lib/edit-snapshot.mjs),事件里只带一个不透明 key,文本由扩展按需走 `/old` 取。
22
+ * - **绝不抛**:`tools/post-execute` 的监听器抛错会把成功的调用变成 `isError`(DSH 源码注释:
23
+ * `a throwing listener → isError`),所以整段包在 try/catch 里,宁可少一次提示。
24
+ * - 只在决策是 `accept` 且 `result.isError !== true` 时记录:被拦下的调用不该弹 diff。
25
+ *
26
+ * **③ 编辑器 → agent**(`tools/pre-execute`):写之前如果该文件在编辑器里是脏的,附一条提示。
19
27
  * - 用 `exec.deferContext(...)` 而不是改写入参:`PreToolDecision` **明确排除了入参改写**
20
28
  * (参数已经进日志与展示了),所以"提醒"是唯一正确的介入方式;
21
29
  * - **不阻断**:误报的代价是模型看到一句提示,而阻断一个正确的编辑会让 agent 卡住;
22
30
  * - 桥不可用/状态过期 → 直接放行,零开销。
23
31
  */
24
32
 
33
+ import { WRITE_TOOLS, absolutePath, extractEditSnapshot, sessionCwdOf } from './edit-snapshot.mjs';
25
34
  import { loadDshExport } from './dsh-resolve.mjs';
26
35
 
27
- /** 会改文件的工具名(各 profile 里的名字不同,所以按"名字 + 参数里有路径"双重判定)。 */
28
- const WRITE_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'create_file', 'apply_patch', 'multi_edit']);
36
+ /** 待配对快照的存活时长(秒级:post-execute 与 tools/result 是同一次调用的前后两步)。 */
37
+ const PENDING_TTL_MS = 30_000;
38
+ /** 待配对快照条数上限(每个文件一条;同一次调用多个 hunk 也只算一条)。 */
39
+ const PENDING_MAX = 16;
29
40
 
30
41
  /** 提示词的来源标记(会话日志里能看出这条来自编辑器桥)。 */
31
42
  const SOURCE_PLUGIN = 'dsh-code-server-app:editor-bridge';
@@ -39,9 +50,10 @@ const DEBOUNCE_MS = 1500;
39
50
  * @param {string} name 工具名
40
51
  * @param {unknown} args 调用参数(可能含 file_path / path)
41
52
  * @param {{meta?: unknown}} result 结果(maybe 带 FsDiffMeta)
53
+ * @param {string|null} [cwd] 会话 cwd(给了就把相对路径展开;不给则原样返回,旧行为)
42
54
  * @returns {string[]} 绝对路径列表(去重)
43
55
  */
44
- export function extractEditedPaths(name, args, result) {
56
+ export function extractEditedPaths(name, args, result, cwd = null) {
45
57
  const paths = new Set();
46
58
  // ① 结果元数据里的 diff(最准:`dsh-tool-fs` 的 write/edit 每个 hunk 一条)
47
59
  const meta = result !== null && typeof result === 'object' ? result.meta : undefined;
@@ -64,7 +76,10 @@ export function extractEditedPaths(name, args, result) {
64
76
  // str_replace_editor 的 `view` 不改文件 —— 别把只读调用也报成改动
65
77
  if (name === 'str_replace_editor' && args.command === 'view') paths.clear();
66
78
  }
67
- return [...paths];
79
+ // `meta.diffs[].path` 是**原样的调用参数**(可能是相对路径,由 DSH 按会话 cwd 展开);
80
+ // 不展开就交给编辑器的话,它会按 IDE 自己的进程 cwd 解析 —— 轻则判"工作区外"丢掉,
81
+ // 重则匹配到另一个同名文件。没有 cwd(非 agent 调用)时保持原样,不猜。
82
+ return [...paths].map((item) => absolutePath(item, cwd) ?? item);
68
83
  }
69
84
 
70
85
  /**
@@ -75,16 +90,44 @@ export function extractEditedPaths(name, args, result) {
75
90
  * emit: (kind: string, fields?: object) => void,
76
91
  * context: () => object|null,
77
92
  * isLive: () => boolean,
93
+ * snapshots?: {put: (path: string, text: string) => string|null},
78
94
  * }} deps
79
95
  * `emit(kind, fields)` 把事件推进 host 的环形缓冲;
80
96
  * `context()` 取编辑器状态缓存(可能为 null);
81
- * `isLive()` 桥是否就绪(用于决定要不要做脏缓冲区检查)。
97
+ * `isLive()` 桥是否就绪(用于决定要不要做脏缓冲区检查);
98
+ * `snapshots` 写前原文缓存(缺省时事件不带 oldKey,扩展退回旧行为)。
82
99
  */
83
100
  export function registerBridgeObserver(ctx, deps) {
84
101
  if (ctx === undefined || ctx === null || typeof ctx.on !== 'function') return null;
85
102
  const disposers = [];
86
103
  /** path → 上次推送时间(去抖)。 */
87
104
  const lastEmit = new Map();
105
+ /** 绝对路径 → 本次调用的写前原文信息(post-execute 记、tools/result 消费)。 */
106
+ const pending = new Map();
107
+
108
+ /** 记下本次调用的写前原文(有界:条数 + 存活时间,长会话里不会涨)。 */
109
+ const rememberSnapshot = (info) => {
110
+ const at = Date.now();
111
+ for (const [key, item] of pending) {
112
+ if (at - item.at > PENDING_TTL_MS) pending.delete(key);
113
+ }
114
+ pending.delete(info.path); // 同一文件的旧记录让位给本次调用
115
+ pending.set(info.path, { info, at });
116
+ while (pending.size > PENDING_MAX) {
117
+ const oldest = pending.keys().next();
118
+ if (oldest.done === true) break;
119
+ pending.delete(oldest.value);
120
+ }
121
+ };
122
+
123
+ /** 取走某个文件的写前原文信息(消费:只有紧随其后的那次事件能用它)。 */
124
+ const takeSnapshot = (filePath) => {
125
+ const item = pending.get(filePath);
126
+ if (item === undefined) return null;
127
+ pending.delete(filePath);
128
+ if (Date.now() - item.at > PENDING_TTL_MS) return null;
129
+ return item.info;
130
+ };
88
131
 
89
132
  const onResult = (exec, result) => {
90
133
  try {
@@ -93,7 +136,8 @@ export function registerBridgeObserver(ctx, deps) {
93
136
  if (name === '') return;
94
137
  const isError = result !== null && typeof result === 'object' && result.isError === true;
95
138
  if (isError) return; // 失败的写没有落点,别把编辑器叫起来
96
- const paths = extractEditedPaths(name, exec.arguments, result);
139
+ const cwd = sessionCwdOf(exec);
140
+ const paths = extractEditedPaths(name, exec.arguments, result, cwd);
97
141
  if (paths.length === 0) return;
98
142
  const now = Date.now();
99
143
  const sessionId = exec.agent !== undefined && exec.agent !== null && exec.agent.session !== undefined
@@ -101,9 +145,32 @@ export function registerBridgeObserver(ctx, deps) {
101
145
  : null;
102
146
  for (const filePath of paths) {
103
147
  const previous = lastEmit.get(filePath);
104
- if (previous !== undefined && now - previous < DEBOUNCE_MS) continue;
148
+ if (previous !== undefined && now - previous < DEBOUNCE_MS) {
149
+ // 去抖丢掉的这条事件,对应的快照也别留着(否则会错配给下一次改动)
150
+ takeSnapshot(filePath);
151
+ continue;
152
+ }
105
153
  lastEmit.set(filePath, now);
106
- deps.emit('agent-edit', { path: filePath, tool: name, sessionId });
154
+ const snapshot = takeSnapshot(filePath);
155
+ // 写前原文走快照缓存(事件里只带不透明 key):/sync 的响应还驮着对话流与待决授权,
156
+ // 往里塞全文会把那一趟拖成超时(0.3.27 的真实事故)。
157
+ let oldKey = null;
158
+ if (snapshot !== null && snapshot.oldSide === 'snapshot' && typeof deps.snapshots?.put === 'function') {
159
+ try {
160
+ oldKey = deps.snapshots.put(filePath, snapshot.beforeText);
161
+ } catch (err) {
162
+ console.warn(`[code-server] 编辑器桥:写前原文入缓存失败 ${err && err.message ? err.message : err}`);
163
+ oldKey = null;
164
+ }
165
+ }
166
+ const fields = { path: filePath, tool: name, sessionId };
167
+ if (snapshot !== null) {
168
+ fields.operation = snapshot.operation ?? null;
169
+ fields.oldSide = oldKey === null ? snapshot.oldSide : 'snapshot';
170
+ if (oldKey !== null) fields.oldKey = oldKey;
171
+ if (snapshot.oldSide === 'too-large') fields.oldBytes = snapshot.bytes;
172
+ }
173
+ deps.emit('agent-edit', fields);
107
174
  }
108
175
  // 去抖表也要有界(长时间会话里文件数会涨)。
109
176
  if (lastEmit.size > 512) {
@@ -117,6 +184,29 @@ export function registerBridgeObserver(ctx, deps) {
117
184
  }
118
185
  };
119
186
 
187
+ /**
188
+ * 写类工具的完整前后文只在 `tools/post-execute` 里可见(execution-local `value`,
189
+ * durable 投影按设计剥掉了它)。**这里抛错会把成功的调用变成 isError** —— 见文件头。
190
+ */
191
+ const onPostExecute = async (exec, result, next) => {
192
+ const decision = await next();
193
+ try {
194
+ if (decision === null || decision === undefined || decision.kind !== 'accept') return decision;
195
+ if (result === null || typeof result !== 'object' || result.isError === true) return decision;
196
+ const info = extractEditSnapshot(
197
+ exec === null || exec === undefined ? '' : exec.name,
198
+ exec === null || exec === undefined ? null : exec.arguments,
199
+ result,
200
+ exec,
201
+ );
202
+ if (info !== null) rememberSnapshot(info);
203
+ } catch (err) {
204
+ // 取值失败最坏是"这次 diff 没有 old 侧",绝不能影响 agent 的调用结果
205
+ console.warn(`[code-server] 编辑器桥:取写前原文失败 ${err && err.message ? err.message : err}`);
206
+ }
207
+ return decision;
208
+ };
209
+
120
210
  const onPreExecute = async (exec, next) => {
121
211
  const decision = await next();
122
212
  try {
@@ -126,13 +216,14 @@ export function registerBridgeObserver(ctx, deps) {
126
216
  if (!WRITE_TOOLS.has(name)) return decision;
127
217
  const args = exec.arguments;
128
218
  if (args === null || typeof args !== 'object') return decision;
129
- const filePath = typeof args.file_path === 'string' ? args.file_path
130
- : (typeof args.path === 'string' ? args.path : null);
219
+ const filePath = absolutePath(typeof args.file_path === 'string' ? args.file_path
220
+ : (typeof args.path === 'string' ? args.path : null), sessionCwdOf(exec));
131
221
  if (filePath === null || filePath === '') return decision;
132
222
  if (name === 'str_replace_editor' && args.command === 'view') return decision;
133
223
  const cached = deps.context();
134
224
  if (cached === null || cached === undefined || cached.context === null) return decision;
135
225
  const dirty = Array.isArray(cached.context.dirtyBuffers) ? cached.context.dirtyBuffers : [];
226
+ // 编辑器上报的是绝对路径,这里也必须绝对化后再比(否则相对路径永远匹配不上)
136
227
  const hit = dirty.find((item) => item !== null && item.path === filePath);
137
228
  if (hit === undefined) return decision;
138
229
  const createUserMessage = await loadDshCreateUserMessage();
@@ -159,6 +250,13 @@ export function registerBridgeObserver(ctx, deps) {
159
250
  } catch (err) {
160
251
  console.warn(`[code-server] 编辑器桥:注册 tools/result 失败 ${err && err.message ? err.message : err}`);
161
252
  }
253
+ try {
254
+ // 写前原文只有这里拿得到(value 是 execution-local,durable 投影里没有)。
255
+ // 旧版 DSH(没有这个观察点)不会报错,只是事件里缺 oldKey —— 扩展退回旧行为。
256
+ disposers.push(ctx.on('tools/post-execute', onPostExecute));
257
+ } catch (err) {
258
+ console.warn(`[code-server] 编辑器桥:注册 tools/post-execute 失败 ${err && err.message ? err.message : err}`);
259
+ }
162
260
  try {
163
261
  disposers.push(ctx.on('tools/pre-execute', onPreExecute));
164
262
  } catch (err) {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * lib/child-node.mjs — 给「VS Code server 子进程」挑一个**真 Node**(0.3.53)。
3
+ *
4
+ * 为什么必须挑:DSH Desktop 的宿主进程是用 **Electron 的 Node 模式**跑的
5
+ * (`ELECTRON_RUN_AS_NODE=1`,`process.execPath` = Electron 二进制;见 harness 的
6
+ * `apps/desktop/src/node-environment.ts` 与 `host-process.ts`)。插件 spawn launcher 时若照旧用
7
+ * `process.execPath`,VS Code 的 server-main 会在启动时注册自己的 ESM 解析钩子,而那段钩子
8
+ * **只在 Electron / ELECTRON_RUN_AS_NODE 下生效**:
9
+ *
10
+ * if (!process.env.ELECTRON_RUN_AS_NODE && !process.versions.electron) return;
11
+ * ...
12
+ * const L = <默认解析结果>;
13
+ * if (!L.startsWith(<应用根>)) { // 树外 → 去 node_modules.asar 找,找不到就抛
14
+ * throw new Error(`Cannot find package '${name}' within the application resources`);
15
+ * }
16
+ *
17
+ * 本插件的原生包是**树外**的 pnpm 目录(`<profile>/node_modules/.pnpm/…`),于是启动路径上的
18
+ * `@vscode/spdlog` / `@vscode/deviceid` / `@vscode/windows-registry` 三个 ESM import 被拒,
19
+ * launcher 打 `FATAL 加载 VS Code server 失败` 后退出 ⇒ 面板显示「code-server 意外退出(exit 2)」。
20
+ * 2026-09-18 实测(同一棵树、同一份 launcher):
21
+ * · Electron(Node 模式)→ 必炸(首行 `Cannot find package '@vscode/spdlog' within the application resources`);
22
+ * · 桌面版自带的 `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`(v24.21.0)→
23
+ * 一次启动成功(`Extension host agent started` + `dshcs-ready`),stderr 全空。
24
+ *
25
+ * 所以这里只做一件事:宿主是 Electron 时**找一个真 Node** 给子进程用,并从子进程环境里删掉
26
+ * `ELECTRON_RUN_AS_NODE`(留着它,真 Node 也会被判成 Electron,钩子照样生效)。普通 node 宿主
27
+ * (web / CLI)原样返回 `process.execPath`,行为一字不变。找不到真 Node 时退回 Electron(与旧行为一致)
28
+ * 并由调用方**明确告警**,不再让人对着 exit 2 猜。
29
+ *
30
+ * 试过但**不能用**的兜底(2026-09-18 实测,别再走一遍):
31
+ * · `VSCODE_DEV=1`:钩子确实被绕过(`s = process.env.VSCODE_DEV ? undefined : …`),但 VS Code 同时切到
32
+ * dev 引导路径,转头就报 `Cannot find module '<树>/lib/vscode/out/bootstrap-import.js'` —— 精简树里没这文件。
33
+ * · 往树内补 junction:Node 的 ESM 解析会 **realpath**(实测 `import.meta.resolve('@vscode/spdlog')` 返回的是
34
+ * pnpm store 路径),所以链接建在树里也仍旧"落在应用根之外",钩子照样拒。
35
+ *
36
+ * 纯路径/环境判断,不碰进程 → 可离线单测(scripts/test-child-node.mjs)。
37
+ */
38
+
39
+ import { existsSync } from 'node:fs';
40
+ import { posix, win32 } from 'node:path';
41
+
42
+ /** 宿主是不是 Electron(含 Node 模式的两个判据)。 */
43
+ export function isElectronHost(versions = process.versions, env = process.env) {
44
+ const flag = env.ELECTRON_RUN_AS_NODE;
45
+ const flagged = typeof flag === 'string' && flag !== '' && flag !== '0' && flag !== 'false';
46
+ return Boolean(versions !== null && versions !== undefined && versions.electron) || flagged;
47
+ }
48
+
49
+ /** 真 Node 的文件名(平台惯例)。 */
50
+ export function nodeBinaryName(platform = process.platform) {
51
+ return platform === 'win32' ? 'node.exe' : 'node';
52
+ }
53
+
54
+ /**
55
+ * 真 Node 候选表(按优先级)。每条都带 `source` 便于日志/断言。
56
+ * @param options.execPath - 宿主可执行文件(通常是 Electron)。
57
+ * @param options.resourcesPath - Electron 的 `process.resourcesPath`(可能 undefined)。
58
+ * @param options.env - 环境变量(PATH / DSH_DESKTOP_NODE_EXECUTABLE)。
59
+ */
60
+ export function childNodeCandidates(options = {}) {
61
+ const execPath = typeof options.execPath === 'string' ? options.execPath : process.execPath;
62
+ const env = options.env ?? process.env;
63
+ const platform = options.platform ?? process.platform;
64
+ const resourcesPath = typeof options.resourcesPath === 'string' ? options.resourcesPath : undefined;
65
+ const bin = nodeBinaryName(platform);
66
+ // 路径风格跟着 platform 走(而不是跟着"跑测试的这台机器"),Linux/macOS 形态才测得到
67
+ const paths = platform === 'win32' ? win32 : posix;
68
+ const join = paths.join;
69
+ const out = [];
70
+ const push = (source, path) => {
71
+ if (typeof path !== 'string' || path === '' || path === execPath) return;
72
+ if (out.some((entry) => entry.path === path)) return;
73
+ out.push({ source, path });
74
+ };
75
+
76
+ if (resourcesPath !== undefined) {
77
+ // ① 桌面版当前布局:主运行时自带的 Node(实测 v24.21.0,唯一被验证可用的那条)
78
+ push('app-primary-runtime-node', join(resourcesPath, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', bin));
79
+ // ② 旧布局(0.1.5 及更早):应用自带一份 node
80
+ push('app-runtime-node', join(resourcesPath, 'runtime', 'node', bin));
81
+ // ③ 通用布局
82
+ push('app-resources-node', join(resourcesPath, 'node', bin));
83
+ }
84
+ // ④ resourcesPath 拿不到时,从可执行文件旁边推
85
+ push('exec-side-runtime-node', join(paths.dirname(execPath), 'resources', 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin', bin));
86
+ push('exec-side-node', join(paths.dirname(execPath), 'resources', 'runtime', 'node', bin));
87
+
88
+ // ⑤ 上游可能直接给了 node 的路径 —— 只有当它**看起来就是 node 二进制**时才认
89
+ // (实测 `DSH_DESKTOP_NODE_EXECUTABLE` 指向的是 Electron 自身,不能用)
90
+ const hinted = env.DSH_DESKTOP_NODE_EXECUTABLE;
91
+ if (typeof hinted === 'string' && hinted !== '' && /^node(\.exe)?$/i.test(hinted.replace(/\\/g, '/').split('/').pop() ?? '')) {
92
+ push('env-dsh-desktop-node', hinted);
93
+ }
94
+
95
+ // ⑥ PATH 上的真 Node(跳过应用自己的 resources —— 那里的 `node`/`node.cmd` 是"再调 Electron"的 shim)
96
+ const pathValue = typeof env.PATH === 'string' ? env.PATH : (typeof env.Path === 'string' ? env.Path : '');
97
+ for (const dir of pathValue.split(paths.delimiter)) {
98
+ if (dir === '') continue;
99
+ if (resourcesPath !== undefined && dir.toLowerCase().startsWith(resourcesPath.toLowerCase())) continue;
100
+ push('path-node', join(dir, bin));
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * 给子进程挑解释器。
107
+ * @returns {{command:string, electronHost:boolean, swapped:boolean, source:string}}
108
+ * · 非 Electron 宿主:`{ command: process.execPath, swapped: false, source: 'host-node' }`(行为不变);
109
+ * · Electron 宿主且找到真 Node:`swapped: true`(调用方须用 childNodeEnv 剥掉 ELECTRON_RUN_AS_NODE);
110
+ * · Electron 宿主但一个候选都不在:`source: 'electron-fallback'`(与旧行为一致,调用方应告警)。
111
+ */
112
+ export function resolveChildNode(options = {}) {
113
+ const execPath = typeof options.execPath === 'string' ? options.execPath : process.execPath;
114
+ const versions = options.versions ?? process.versions;
115
+ const env = options.env ?? process.env;
116
+ const exists = typeof options.exists === 'function' ? options.exists : existsSync;
117
+ const electronHost = isElectronHost(versions, env);
118
+ if (!electronHost) return { command: execPath, electronHost: false, swapped: false, source: 'host-node' };
119
+ for (const candidate of childNodeCandidates({ execPath, resourcesPath: options.resourcesPath ?? process.resourcesPath, env, platform: options.platform })) {
120
+ try {
121
+ if (exists(candidate.path)) {
122
+ return { command: candidate.path, electronHost: true, swapped: true, source: candidate.source };
123
+ }
124
+ } catch { /* 下一个候选 */ }
125
+ }
126
+ return { command: execPath, electronHost: true, swapped: false, source: 'electron-fallback' };
127
+ }
128
+
129
+ /**
130
+ * 子进程环境:用真 Node 时**必须**删掉 `ELECTRON_RUN_AS_NODE`,否则 VS Code 的 asar 解析钩子照样生效
131
+ * (钩子的判据是"环境变量或 versions.electron 二者之一")。退回 Electron 时反而要留着它,否则
132
+ * Electron 会当 GUI 应用启动。
133
+ */
134
+ export function childNodeEnv(resolved, env = process.env) {
135
+ const next = { ...env };
136
+ if (resolved !== null && resolved !== undefined && resolved.swapped === true) delete next.ELECTRON_RUN_AS_NODE;
137
+ return next;
138
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * lib/edit-snapshot.mjs — 写前原文快照:把"改动前的完整内容"从工具调用里取出来(0.3.55)。
3
+ *
4
+ * **为什么需要它**(用户实测到的症状:diff 左侧空、右侧全文):
5
+ * 扩展侧拿 old 侧的渠道以前只有两条 —— ① 编辑器里**打开着**的缓冲区;② 它自己上次看过的缓存。
6
+ * 文件没在编辑器里打开(最常见:agent 直接改一个新文件)时两条都落空 ⇒ 左栏是空文本,
7
+ * 标题写着"没有改动前的内容"。而"写之前文件长什么样"这件事,**只有 host 知道**。
8
+ *
9
+ * **为什么不用 `tools/result`**:那条通道是 emit 型 + durable 投影,`ToolResult` 里**故意**没有
10
+ * `value`(见 DSH `packages/core/tools/src/index.ts`:`Execution-local canonical value;
11
+ * deliberately omitted from durable events`)。真正的完整前后文在 `ToolExecutionSuccess.value` 里:
12
+ * · `write` → `{path, operation, before, after}`(`before` 是写前全文,新建时为 `null`)
13
+ * · `edit` → `{path, before, after}`(都是**整份文件**文本,不是 hunk)
14
+ * 所以取值点在 `tools/post-execute`(waterfall,能看见 live 的 `result.value`)。
15
+ *
16
+ * **两条硬约束**(踩了会出真事故,别改):
17
+ * 1. `tools/post-execute` 的监听器**抛错会把成功的调用变成 `isError`**
18
+ * (DSH 源码注释:`Runs inside execute's outer try/catch (a throwing listener → isError)`)。
19
+ * ⇒ 调用方必须整体 try/catch;本模块的每个函数都设计成"宁可返回 null,绝不抛"。
20
+ * 2. `value` 是 **execution-local**:会话日志/replay 里没有它,只有当场那一次能拿到。
21
+ *
22
+ * **路径可能是相对的**:`value.path` 是 `displayPath`(文档明说"可能是工作区相对路径"),
23
+ * `meta.diffs[].path` 更是**原样的调用参数**。相对路径由 DSH 按**会话 cwd**
24
+ * (`exec.agent.session.header.cwd`)展开 —— 扩展侧拿到相对路径会去按 IDE 自己的 cwd 解析,
25
+ * 于是 `isInWorkspace()` 判否(不出 diff)或匹配到另一个文件。所以这里统一绝对化。
26
+ *
27
+ * 纯逻辑(不 import DSH、不碰 cordis),因此可以直接单测:scripts/test-edit-snapshot.mjs。
28
+ */
29
+
30
+ import { randomBytes } from 'node:crypto';
31
+ import path from 'node:path';
32
+
33
+ /** 会改文件的工具名(各 profile 里的名字不同,所以按"名字 + 参数里有路径"双重判定)。 */
34
+ export const WRITE_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'create_file', 'apply_patch', 'multi_edit']);
35
+
36
+ /** 单份快照上限:超过就不存(改由扩展回退到缓冲区/缓存,并在标题里说明原因)。 */
37
+ export const MAX_SNAPSHOT_BYTES = 1024 * 1024;
38
+ /** 快照缓存总量上限(它是为了"下一次轮询",不是版本控制)。 */
39
+ export const SNAPSHOT_BUDGET_BYTES = 4 * 1024 * 1024;
40
+ /** 快照条数上限(扩展每 600ms 取一次,8 条足够覆盖"这一批改动")。 */
41
+ export const MAX_SNAPSHOTS = 8;
42
+ /** 快照存活时长:过期即丢(没人来取 = 扩展没在跑,留着只是占内存)。 */
43
+ export const SNAPSHOT_TTL_MS = 5 * 60 * 1000;
44
+
45
+ /** 本次调用会话的工作目录(DSH 用它展开相对路径;非 agent 调用没有)。 */
46
+ export function sessionCwdOf(exec) {
47
+ const cwd = exec === null || exec === undefined || exec.agent === null || exec.agent === undefined
48
+ ? undefined
49
+ : exec.agent.session === undefined || exec.agent.session === null
50
+ ? undefined
51
+ : exec.agent.session.header === undefined || exec.agent.session.header === null
52
+ ? undefined
53
+ : exec.agent.session.header.cwd;
54
+ return typeof cwd === 'string' && cwd !== '' ? cwd : null;
55
+ }
56
+
57
+ /**
58
+ * 把工具给的路径变成绝对路径。
59
+ *
60
+ * 没有 cwd(非 agent 调用)或本来就绝对时不做猜测:绝对路径只做归一化,相对路径**原样返回**
61
+ * (扩展侧对它保持旧行为:不在工作区内就跳过 —— 宁可不显示,也不要打开错的文件)。
62
+ *
63
+ * @param {unknown} value 工具给的路径
64
+ * @param {string|null} cwd 会话 cwd
65
+ * @returns {string|null}
66
+ */
67
+ export function absolutePath(value, cwd) {
68
+ if (typeof value !== 'string' || value === '') return null;
69
+ if (path.isAbsolute(value)) return path.normalize(value);
70
+ if (typeof cwd !== 'string' || cwd === '') return value;
71
+ return path.resolve(cwd, value);
72
+ }
73
+
74
+ /** UTF-8 字节数(内存预算按它算,不是按字符数)。 */
75
+ function byteLength(text) {
76
+ return Buffer.byteLength(text, 'utf8');
77
+ }
78
+
79
+ /** 取第一个非空字符串(按给定顺序)。 */
80
+ function firstString(values) {
81
+ for (const value of values) {
82
+ if (typeof value === 'string' && value !== '') return value;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * 从一次工具结果里抽出"这次写操作的写前原文"。
89
+ *
90
+ * `oldSide` 的取值(扩展侧据此决定标题与左栏):
91
+ * · `snapshot` 拿到了完整写前文本(见 `beforeText`);
92
+ * · `create` 明确的新建(`operation === 'create'`,或 `before === null`)⇒ 左栏本来就该是空的;
93
+ * · `too-large` 写前文本超过 {@link MAX_SNAPSHOT_BYTES} ⇒ 不传,扩展回退;
94
+ * · `unavailable` 工具没给文本(`str_replace_editor` 这类 output 是纯字符串的工具,或旧版 DSH)。
95
+ *
96
+ * @param {string} name 工具名
97
+ * @param {unknown} args 调用参数
98
+ * @param {{isError?: boolean, value?: unknown}} result 结果(成功时带 execution-local `value`)
99
+ * @param {unknown} exec 调用上下文(只为取会话 cwd)
100
+ * @returns {{path: string, operation: string|null, oldSide: string, beforeText: string|null, bytes: number}|null}
101
+ * null = 这次不是"能取出写前原文的写操作"
102
+ */
103
+ export function extractEditSnapshot(name, args, result, exec) {
104
+ if (typeof name !== 'string' || !WRITE_TOOLS.has(name)) return null;
105
+ if (result === null || typeof result !== 'object') return null;
106
+ if (result.isError === true) return null;
107
+ const argv = args !== null && typeof args === 'object' ? args : null;
108
+ // str_replace_editor 的 `view` 不改文件 —— 别把只读调用也报成改动
109
+ if (name === 'str_replace_editor' && argv !== null && argv.command === 'view') return null;
110
+
111
+ const value = result.value !== null && result.value !== undefined && typeof result.value === 'object' ? result.value : null;
112
+ const raw = firstString([
113
+ value === null ? null : value.path,
114
+ argv === null ? null : argv.file_path,
115
+ argv === null ? null : argv.path,
116
+ argv === null ? null : argv.file,
117
+ ]);
118
+ const abs = absolutePath(raw, sessionCwdOf(exec));
119
+ if (abs === null) return null;
120
+
121
+ const operation = value !== null && (value.operation === 'create' || value.operation === 'update') ? value.operation : null;
122
+ const hasBefore = value !== null && Object.hasOwn(value, 'before');
123
+ const before = hasBefore ? value.before : undefined;
124
+
125
+ // 新建:写前没有内容。`before === null` 只可能出现在新建(fs 层"存在但读不到内容"不会发生:
126
+ // 写前必读,写与读之间还有版本栅栏),所以它和 `operation === 'create'` 同义。
127
+ if (operation === 'create' || before === null) {
128
+ return { path: abs, operation: operation ?? 'create', oldSide: 'create', beforeText: null, bytes: 0 };
129
+ }
130
+ if (typeof before === 'string') {
131
+ const bytes = byteLength(before);
132
+ if (bytes > MAX_SNAPSHOT_BYTES) {
133
+ return { path: abs, operation, oldSide: 'too-large', beforeText: null, bytes };
134
+ }
135
+ return { path: abs, operation, oldSide: 'snapshot', beforeText: before, bytes };
136
+ }
137
+ return { path: abs, operation, oldSide: 'unavailable', beforeText: null, bytes: 0 };
138
+ }
139
+
140
+ /**
141
+ * `/old` 路由的响应(纯逻辑,便于单测:路由本体只是"过闸 + 解析 key + jsonResponse")。
142
+ *
143
+ * 语义:
144
+ * · 缺 key → 400(调用方写错了,不是"过期");
145
+ * · 取不到 → 404(过期 / 被淘汰 / 宿主重启 —— **正常路径**,扩展据此回退到缓冲区,不重试);
146
+ * · 取到 → 200 + 文本(不消费,重复取拿到同一份)。
147
+ *
148
+ * @param {{get: (key: string) => ({path: string, text: string, bytes: number}|null)}|null} store 快照缓存
149
+ * @param {string|null} key 事件里的 oldKey
150
+ * @returns {{status: number, body: object}}
151
+ */
152
+ export function snapshotResponse(store, key) {
153
+ if (typeof key !== 'string' || key === '') {
154
+ return { status: 400, body: { ok: false, error: '需要 key 参数' } };
155
+ }
156
+ const snapshot = store === null || store === undefined || typeof store.get !== 'function' ? null : store.get(key);
157
+ if (snapshot === null || snapshot === undefined) {
158
+ return { status: 404, body: { ok: false, error: '快照已失效' } };
159
+ }
160
+ return { status: 200, body: { ok: true, path: snapshot.path, text: snapshot.text, bytes: snapshot.bytes } };
161
+ }
162
+
163
+ /**
164
+ * 有界快照缓存:host 存写前原文,扩展拿着**不透明 key** 来取。
165
+ *
166
+ * 为什么不让事件直接带文本:扩展每 600ms 的 `/sync` 响应还驮着对话流与待决授权,
167
+ * 里面塞过 ~1MB 的文本会把那一趟拖成超时(0.3.27 的真实事故:超时 ⇒ approvals 一起丢 ⇒
168
+ * 授权卡片永远不出现)。所以事件只带 key,文本走独立的 `/old` 路由按需取。
169
+ *
170
+ * key 是不透明随机串(不是路径、不是序号):即使令牌泄露,能读到的也只是"最近几次 agent 写操作
171
+ * 的写前内容"这一份有界缓存,拿不到任意文件。
172
+ *
173
+ * @param {{maxEntries?: number, budgetBytes?: number, maxBytes?: number, ttlMs?: number, now?: () => number}} [options]
174
+ */
175
+ export function createSnapshotStore(options = {}) {
176
+ const maxEntries = Number.isSafeInteger(options.maxEntries) && options.maxEntries > 0 ? options.maxEntries : MAX_SNAPSHOTS;
177
+ const budgetBytes = Number.isSafeInteger(options.budgetBytes) && options.budgetBytes > 0 ? options.budgetBytes : SNAPSHOT_BUDGET_BYTES;
178
+ const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : MAX_SNAPSHOT_BYTES;
179
+ const ttlMs = Number.isSafeInteger(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : SNAPSHOT_TTL_MS;
180
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
181
+
182
+ /** key → {path, text, bytes, at}。插入顺序 = 时间顺序(淘汰最旧的用)。 */
183
+ const items = new Map();
184
+ let total = 0;
185
+
186
+ function drop(key) {
187
+ const item = items.get(key);
188
+ if (item === undefined) return;
189
+ items.delete(key);
190
+ total -= item.bytes;
191
+ }
192
+
193
+ /** 过期清理 + 容量淘汰(put/get 时顺手做,不额外起定时器)。 */
194
+ function reap() {
195
+ const at = now();
196
+ for (const [key, item] of items) {
197
+ if (at - item.at > ttlMs) drop(key);
198
+ }
199
+ while (items.size > maxEntries || total > budgetBytes) {
200
+ const oldest = items.keys().next();
201
+ if (oldest.done === true) break;
202
+ drop(oldest.value);
203
+ }
204
+ }
205
+
206
+ return {
207
+ /**
208
+ * 存一份写前原文。
209
+ * @returns {string|null} 不透明 key;不存(超限/非法)时返回 null
210
+ */
211
+ put(filePath, text) {
212
+ if (typeof filePath !== 'string' || filePath === '' || typeof text !== 'string') return null;
213
+ const bytes = byteLength(text);
214
+ if (bytes > maxBytes || bytes > budgetBytes) return null;
215
+ reap();
216
+ const key = randomBytes(9).toString('base64url');
217
+ items.set(key, { path: filePath, text, bytes, at: now() });
218
+ total += bytes;
219
+ reap();
220
+ return key;
221
+ },
222
+ /** 取一份(不消费:扩展可能重复轮询同一条事件)。过期/未知 → null。 */
223
+ get(key) {
224
+ if (typeof key !== 'string' || key === '') return null;
225
+ reap();
226
+ const item = items.get(key);
227
+ if (item === undefined) return null;
228
+ return { path: item.path, text: item.text, bytes: item.bytes, at: item.at };
229
+ },
230
+ size() {
231
+ reap();
232
+ return items.size;
233
+ },
234
+ bytes() {
235
+ return total;
236
+ },
237
+ clear() {
238
+ items.clear();
239
+ total = 0;
240
+ },
241
+ };
242
+ }
package/lib/index.js CHANGED
@@ -54,7 +54,9 @@ import { bridgeEndpointPath, startBridgeListener } from './bridge-ipc.mjs';
54
54
  import { registerEditorPrompt, registerEditorTools, setPromptLiveProbe } from './bridge-tools.mjs';
55
55
  import { deliverEditorPrompt } from './bridge-session.mjs';
56
56
  import { dshEntry, dshRequire } from './dsh-resolve.mjs';
57
+ import { childNodeEnv, resolveChildNode } from './child-node.mjs';
57
58
  import { registerBridgeObserver } from './bridge-observe.mjs';
59
+ import { createSnapshotStore, snapshotResponse } from './edit-snapshot.mjs';
58
60
  import { createThreadRegistry } from './bridge-thread.mjs';
59
61
  import { createApprovalBoard, createApprovalInterceptor, DEFAULT_HOLD_MS, PANEL_OUTCOMES } from './bridge-approval.mjs';
60
62
 
@@ -732,6 +734,10 @@ export async function apply(ctx, config) {
732
734
  let bridgeAnswerDispose = null;
733
735
  /** 推给编辑器的事件环形缓冲(tools/result → 扩展轮询)。 */
734
736
  const bridgeEvents = createEventRing();
737
+ /** 写前原文快照缓存(0.3.55):事件里带不透明 key,扩展走 GET /old 取文本。
738
+ * 有界(条数/字节/存活时长),所以"host 侧不持有文件内容"这条不变量仍然成立 ——
739
+ * 它持有的是最近几次 agent 写操作的写前副本,且随时会被淘汰。 */
740
+ const bridgeSnapshots = createSnapshotStore();
735
741
  /** 编辑器状态缓存:扩展在每次 /sync 里推上来,agent 的工具调用来读它。 */
736
742
  const bridgeContext = createContextCache();
737
743
  /** 被面板观看的会话 → 对话条目流(只渲染新内容;见 lib/bridge-thread.mjs)。 */
@@ -1083,6 +1089,7 @@ export async function apply(ctx, config) {
1083
1089
  return [
1084
1090
  { suffix: '/health', methods: ['GET'], fetch: handleBridgeHealth },
1085
1091
  { suffix: '/sync', methods: ['POST'], fetch: handleBridgeSync },
1092
+ { suffix: '/old', methods: ['GET'], fetch: handleBridgeOld },
1086
1093
  { suffix: '/ask', methods: ['POST'], fetch: handleBridgeAsk },
1087
1094
  { suffix: '/event', methods: ['POST'], fetch: handleBridgeEvent },
1088
1095
  { suffix: '/approve', methods: ['POST'], fetch: handleBridgeApprove },
@@ -1148,6 +1155,7 @@ export async function apply(ctx, config) {
1148
1155
  emit: (kind, fields) => bridgeEvents.push(kind, fields),
1149
1156
  context: () => bridgeContext.get(),
1150
1157
  isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
1158
+ snapshots: bridgeSnapshots,
1151
1159
  });
1152
1160
 
1153
1161
  // 会话流与授权拦截都是**按会话**建立的(在 /ask 成功时 watch + intercept),
@@ -1236,6 +1244,7 @@ export async function apply(ctx, config) {
1236
1244
  bridgeToolDispose = disposeSafely(bridgeToolDispose);
1237
1245
  bridgeContext.clear();
1238
1246
  bridgeEvents.reset();
1247
+ bridgeSnapshots.clear();
1239
1248
  if (bridgeMeta === null) return;
1240
1249
  bridgeMeta = null;
1241
1250
  for (const dir of bridgeConfigDirs()) {
@@ -1675,13 +1684,30 @@ export async function apply(ctx, config) {
1675
1684
  // 启动前再自愈一次依赖布局(插件重装/树被替换后可能丢失;幂等且只做存在性检查)
1676
1685
  ensureRuntimeLayout();
1677
1686
  const isCmd = launch.kind === 'bin' && win32() && /\.cmd$/i.test(launch.command);
1678
- const command = launch.kind === 'bin' ? launch.command : process.execPath;
1687
+ // 解释器:显式 bin 用它的;其余(node 脚本 / 自带 launcher)默认 process.execPath —— 但**宿主是
1688
+ // Electron(Node 模式)时必须换成真 Node**(0.3.53):VS Code 的 server-main 会注册一段只在
1689
+ // Electron/ELECTRON_RUN_AS_NODE 下生效的 asar 解析钩子,它拒绝一切落在"应用根"之外的包,而
1690
+ // 本插件的原生包都在树外的 pnpm 目录里 ⇒ 桌面版必然 `Cannot find package '@vscode/spdlog' …`
1691
+ // ⇒ 面板显示「code-server 意外退出(exit 2)」。详见 lib/child-node.mjs。
1692
+ const childNode = launch.kind === 'bin' ? null : resolveChildNode();
1693
+ const command = launch.kind === 'bin' ? launch.command : childNode.command;
1679
1694
  const spawnArgs = launch.kind === 'bin' ? args : [launch.script, ...args];
1695
+ if (childNode !== null && childNode.electronHost) {
1696
+ if (childNode.swapped) {
1697
+ console.log(`[code-server] 宿主是 Electron(Node 模式):改用真 Node 跑 IDE 子进程`
1698
+ + `(${childNode.source}: ${childNode.command});已从子进程环境剥离 ELECTRON_RUN_AS_NODE`);
1699
+ } else {
1700
+ console.warn('[code-server] 宿主是 Electron(Node 模式),但没找到可用的真 Node —— VS Code 的 asar 解析钩子'
1701
+ + '会拒绝树外的原生包(@vscode/spdlog / @vscode/deviceid / @vscode/windows-registry 等),'
1702
+ + 'IDE 很可能以 exit 2 退出。修法:让应用自带的 resources/runtime/primary-runtime/dependencies/node/bin/'
1703
+ + '或 PATH 上有一个真的 node。');
1704
+ }
1705
+ }
1680
1706
  // shell 仅对 .cmd shim(Windows npm 全局包)必要:它必须经 cmd.exe 解析。
1681
1707
  // 含空格路径由 spawn 数组传参,不再经 shell 拼接,避免 'C:\Program' 拆分。
1682
1708
  proc = spawn(isCmd ? `"${command}"` : command, spawnArgs, {
1683
1709
  cwd: cwd ?? process.cwd(),
1684
- env,
1710
+ env: childNode === null ? env : childNodeEnv(childNode, env),
1685
1711
  shell: isCmd,
1686
1712
  windowsHide: true,
1687
1713
  detached: !win32() && !isCmd,
@@ -1944,6 +1970,29 @@ export async function apply(ctx, config) {
1944
1970
  });
1945
1971
  }
1946
1972
 
1973
+ /**
1974
+ * 取一份"写前原文"快照(0.3.55)。
1975
+ *
1976
+ * 为什么单独一条路由:事件里塞不下全文 —— `/sync` 的响应还驮着对话流与待决授权,
1977
+ * 0.3.27 就是因为每趟塞 ~1MB 被拖成超时,而超时会把 approvals 一起丢掉(授权卡片再也不出现)。
1978
+ * 所以事件只带不透明 key,文本按需取。
1979
+ *
1980
+ * 只读且**不消费**:扩展可能重复轮询同一条事件,取两次必须拿到同一份。
1981
+ * key 是随机串,不是路径 —— 即使令牌泄露也只能读到这份有界缓存里的内容,取不到任意文件。
1982
+ */
1983
+ async function handleBridgeOld(request) {
1984
+ const denied = bridgeRejection(request);
1985
+ if (denied !== null) return denied;
1986
+ let key = null;
1987
+ try {
1988
+ key = new URL(request.url).searchParams.get('key');
1989
+ } catch {
1990
+ key = null;
1991
+ }
1992
+ const { status, body } = snapshotResponse(bridgeSnapshots, key);
1993
+ return jsonResponse(body, status);
1994
+ }
1995
+
1947
1996
  async function handleBridgeSync(request) {
1948
1997
  const denied = bridgeRejection(request);
1949
1998
  if (denied !== null) return denied;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-code-server-app",
3
- "version": "0.3.52",
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.",
3
+ "version": "0.3.55",
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). Since 0.3.55 the diff the bridge opens always has the real pre-write text on the left, even for files that are not open in the editor: the host reads the execution-local result.value.before in tools/post-execute (the durable tools/result projection deliberately omits value) into a bounded snapshot cache and the extension fetches it by opaque key over the read-only GET /old route; paths are resolved against the session cwd first, so relative file_path arguments are no longer dropped by the editor's workspace check. 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",
@@ -46,6 +46,8 @@
46
46
  "lib/bridge-thread.mjs",
47
47
  "lib/bridge-approval.mjs",
48
48
  "lib/dsh-resolve.mjs",
49
+ "lib/child-node.mjs",
50
+ "lib/edit-snapshot.mjs",
49
51
  "lib/launcher.mjs",
50
52
  "lib/serve-dsh.mjs",
51
53
  "lib/vendor.js",
@@ -192,7 +194,10 @@
192
194
  "test:client-cwd": "node scripts/test-client-bundle-cwd.mjs",
193
195
  "test:client-seat": "node scripts/test-client-settings-seat.mjs",
194
196
  "test:dsh-resolve": "node scripts/test-dsh-resolve.mjs",
197
+ "test:child-node": "node scripts/test-child-node.mjs",
198
+ "test:package-files": "node scripts/test-package-files.mjs",
195
199
  "test:bridge-routes": "node scripts/test-bridge-routes.mjs",
200
+ "test:edit-snapshot": "node scripts/test-edit-snapshot.mjs",
196
201
  "test:bridge-extension": "node scripts/test-bridge-extension.mjs",
197
202
  "test:webview": "node scripts/test-webview-bundle.mjs",
198
203
  "test:vendored": "node scripts/test-vendored-table.mjs"
@@ -3,7 +3,7 @@
3
3
  "vscodeVersion": "1.137.0",
4
4
  "productPath": "stable-b11dabdaca0d3369986975be285db92c8795cea5",
5
5
  "layout": "vscode-only",
6
- "preparedAt": "2026-09-18T13:40:53.510Z",
6
+ "preparedAt": "2026-09-19T09:11:10.218Z",
7
7
  "source": "registry",
8
8
  "node": "v24.20.0",
9
9
  "platform": "linux",