dsh-code-server-app 0.3.54 → 0.3.56
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 +28 -4
- package/README.md +25 -4
- package/assets/extensions/dshcs-editor-bridge/extension.js +39 -11
- package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +26 -0
- package/assets/extensions/dshcs-editor-bridge/lib/diff-model.js +66 -9
- package/assets/extensions/dshcs-editor-bridge/package.json +1 -1
- package/assets/extensions/dshcs-editor-bridge/webview/THIRD-PARTY.md +1 -1
- package/lib/bridge-observe.mjs +114 -16
- package/lib/bridge.mjs +14 -3
- package/lib/edit-snapshot.mjs +242 -0
- package/lib/index.js +39 -0
- package/package.json +4 -2
- package/vendor/VENDOR.json +1 -1
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
|
|
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.
|
|
@@ -311,6 +311,17 @@ of passing stale data off as fresh).
|
|
|
311
311
|
request/response round trip every 600 ms. Polling also buys two useful properties: it is idempotent (a dropped event
|
|
312
312
|
only costs one notification — the data always lives in the editor) and the cached state is inherently fresh.
|
|
313
313
|
|
|
314
|
+
**Event delivery contract (fixed in 0.3.56)**: `agent-edit` notifications live in a ring buffer and are fetched with
|
|
315
|
+
`since=<cursor>`; **the sequence is monotonic for the lifetime of one bridge endpoint (host process)**, the client's
|
|
316
|
+
cursor only moves forward, and a new endpoint (the pipe name carries the host pid) re-aligns with `since=0`.
|
|
317
|
+
From 0.3.9 through 0.3.55 the host called `reset()` after **every** `/sync`, and that call rewound the sequence
|
|
318
|
+
counter — so once the extension had seen `seq=1`, every later event was numbered 1 again and `seq > since` never
|
|
319
|
+
held: **at most one event per IDE session was ever delivered** (exactly the "a diff rarely shows up, and when it does
|
|
320
|
+
its old side is empty" the user reported). Now `reset()` only clears the buffer, `/sync` also returns `lastSeq` (the
|
|
321
|
+
high-water mark), and the extension uses it to notice a cursor that ran ahead and re-align to 0 on its own.
|
|
322
|
+
Regression: the "push → take → clear, twice" case in `scripts/test-bridge-routes.mjs`. This remains a
|
|
323
|
+
**notification channel**, not a reliable queue: 64 entries, oldest dropped, a lost event costs one notification.
|
|
324
|
+
|
|
314
325
|
### Security model (five invariants; read before touching `lib/bridge.mjs`)
|
|
315
326
|
|
|
316
327
|
The token lives in `<extensionsDir>/.dshcs-bridge/bridge.json`, **readable by any process of the same local
|
|
@@ -320,6 +331,10 @@ user**, so:
|
|
|
320
331
|
edits documents, runs commands, or spawns processes. A leaked token is therefore bounded to "sees information
|
|
321
332
|
that is in the editor" and **can never** become arbitrary file writes or command execution. A whitelist
|
|
322
333
|
assertion in `scripts/test-bridge-routes.mjs` guards this.
|
|
334
|
+
`/old` (added in 0.3.55) lives under the same invariant: it only reads the bounded cache of "pre-write copies of
|
|
335
|
+
the last few agent writes" (≤8 entries, ≤1 MB each, ≤4 MB total, 5-minute TTL) by **opaque key**, 404s when it
|
|
336
|
+
is gone, takes no path argument (so it cannot read arbitrary files) and does not consume (repeat polls get the
|
|
337
|
+
same text).
|
|
323
338
|
2. **The four constraints on `/approve`** (drop one and it becomes an arbitrary-command-execution back door):
|
|
324
339
|
(a) it can only **answer** an approval request that already exists — the body is exactly `{id, outcome}`, with
|
|
325
340
|
**no free text, paths, or command arguments**, so it can answer questions but never start an action;
|
|
@@ -334,7 +349,8 @@ user**, so:
|
|
|
334
349
|
bridge would be a "did you guess the token right" oracle for a web page.
|
|
335
350
|
4. **Paths are confined to the editor's current workspace folders.**
|
|
336
351
|
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)
|
|
352
|
+
≤120 thread entries per session (≤8000 chars each, ≤4 watched sessions), ≤4 pending approvals and ≤8 pre-write
|
|
353
|
+
snapshots (≤1 MB each, ≤4 MB total, 5-minute TTL — see `/old`).
|
|
338
354
|
|
|
339
355
|
This layer stops "another local app or a browser page that got hold of the file". A malicious program running as
|
|
340
356
|
the same user could read your files and the token anyway — that is outside this plugin's threat model, exactly
|
|
@@ -583,8 +599,9 @@ The regression suite (also the single list CI uses) is:
|
|
|
583
599
|
pnpm test # runs them all: scripts/run-all-tests.mjs
|
|
584
600
|
pnpm test:apply # apply() under a stub ctx
|
|
585
601
|
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:
|
|
602
|
+
pnpm test:bridge-routes # bridge route whitelist (read-only + /approve + /old) / Origin-vs-token order / token header agreement
|
|
603
|
+
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
|
|
604
|
+
pnpm test:bridge-extension # extension-side pure logic (dirty buffers, diagnostics, diff old-side priority, delivery, panel state)
|
|
588
605
|
pnpm test:webview # panel bundle: official renderer + tokens, version match, the four /approve constraints
|
|
589
606
|
pnpm test:launcher-routes # launcher HTTP surface (spawns a real process; slow)
|
|
590
607
|
pnpm test:workspace-switch # switching workspaces does not restart the process
|
|
@@ -1061,6 +1078,13 @@ What remains on the plugin side:
|
|
|
1061
1078
|
disk. What the bridge adds is a notice *before* writing a dirty file, a diff *after*, and a warning instead of
|
|
1062
1079
|
an overwrite. It does not decide whether the user saves — that would mean changing the agent's read path,
|
|
1063
1080
|
which is out of scope for this version.
|
|
1081
|
+
- **The diff's left side is the pre-write disk content (since 0.3.55).** It comes from `result.value.before` in
|
|
1082
|
+
`tools/post-execute` (`write`/`edit` both hand over the whole file), so a **file that is not open in the editor
|
|
1083
|
+
still gets a complete left side** — up to 0.3.54 the only sources were "the editor's live buffer" and "the
|
|
1084
|
+
extension's own cache", and when neither hit you got an empty left pane titled "no pre-change content".
|
|
1085
|
+
Two cases still come up empty and the tab title says so: tools whose output is a plain string
|
|
1086
|
+
(`str_replace_editor` — no `value` at all) and pre-write content larger than 1 MB (not cached).
|
|
1087
|
+
Note `value` is execution-local: it never reaches the session log, so a host restart cannot replay an old diff.
|
|
1064
1088
|
|
|
1065
1089
|
- ~~No sub-path~~ **no longer true (corrected with measurements in 0.2.0)**: the workbench HTML VS Code renders references
|
|
1066
1090
|
**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**
|
|
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 界面上的对话框**):
|
|
@@ -296,6 +296,15 @@ host 反向请求不到它。所以编辑器状态只能在扩展主动发起的
|
|
|
296
296
|
**为什么不用 SSE/WebSocket**:扩展宿主里没有 HTTP 服务器,而桥的形态是"每 600ms 一趟请求/响应"。
|
|
297
297
|
轮询给了两条好性质:幂等(丢一次事件只是少一次提示,数据本身永远在编辑器里),以及状态天然最新(每趟都刷新)。
|
|
298
298
|
|
|
299
|
+
**事件的送达契约(0.3.56 修正)**:`agent-edit` 这类提示走环形缓冲,靠 `since=<游标>` 取"比我新的那些";
|
|
300
|
+
**seq 在一次桥端点(宿主进程)生命周期内单调递增**,客户端游标只增不减,端点换了(管道名里带宿主 pid)
|
|
301
|
+
才用 `since=0` 重新对齐。0.3.9–0.3.55 的宿主在**每趟** `/sync` 后都调用 `reset()`,而它当时会把 seq 归零
|
|
302
|
+
⇒ 扩展第一次收到 `seq=1` 后,新事件又从 1 开始编号,`seq > since` 永远不成立 ⇒ **每个 IDE 会话最多只送达
|
|
303
|
+
第一条事件**(用户看到的现象就是"偶尔才有一条 diff,而那条还是空 old")。现在:`reset()` 只清缓冲、不回退
|
|
304
|
+
seq,并且 `/sync` 回应里带 `lastSeq`(高水位),扩展据此自查游标是否超前(超前就退回 0 重新对齐,自愈)。
|
|
305
|
+
回归:`scripts/test-bridge-routes.mjs` 里有一条按真实调用顺序复刻"推送→取→清空"两次的用例。
|
|
306
|
+
注意这仍是**提示通道**,不是可靠队列:缓冲 64 条,超出丢最旧,漏一次只是少一次提示。
|
|
307
|
+
|
|
299
308
|
### 安全模型(五条不变量,改 `lib/bridge.mjs` 之前先读)
|
|
300
309
|
|
|
301
310
|
桥的令牌写在 `<extensionsDir>/.dshcs-bridge/bridge.json`(**对本机同用户进程可读**),所以:
|
|
@@ -303,6 +312,9 @@ host 反向请求不到它。所以编辑器状态只能在扩展主动发起的
|
|
|
303
312
|
1. **`/code-server-bridge/*` 只读,只有 `/approve` 一个例外。** 没有写文件、改文档、执行命令、
|
|
304
313
|
拉起进程的路由。令牌泄露的爆炸半径被封在"看到编辑器里的信息",**不会**变成任意文件写/任意命令执行。
|
|
305
314
|
`scripts/test-bridge-routes.mjs` 里有一条白名单断言盯着这件事(未知后缀一律 404)。
|
|
315
|
+
`/old`(0.3.55 新增)也在这条不变量里:它只能按**不透明 key** 读"最近几次 agent 写操作的写前副本"
|
|
316
|
+
这一份有界缓存(条数 ≤8 / 单份 ≤1MB / 总量 ≤4MB / 5 分钟过期),取不到就 404;
|
|
317
|
+
它不接受路径参数,所以读不到任意文件,也不消费(重复轮询拿到同一份)。
|
|
306
318
|
2. **`/approve` 的四条约束(缺一条就等于开了任意命令执行的后门,不许放宽)**:
|
|
307
319
|
(a) 只能**回答**已经存在的授权请求,请求体只有 `{id, outcome}`,**不接受任何自由文本 / 路径 / 命令参数**
|
|
308
320
|
—— 它只能"回答问题",不能"发起动作";(b) `id` 必须是本进程自己发起、且**仍未决**的请求(用后即废);
|
|
@@ -318,7 +330,8 @@ host 反向请求不到它。所以编辑器状态只能在扩展主动发起的
|
|
|
318
330
|
会让这道 403 静默失效(测试里有这条实测记录)。
|
|
319
331
|
4. **路径收敛在编辑器当前工作区**(`workspaceFolder` 之外的诊断直接丢弃)。
|
|
320
332
|
5. **有界**:诊断默认 200 条 / 单条截断 500 字符 / 上报体上限 256KB / 事件环形缓冲 64 条 /
|
|
321
|
-
对话流每会话 ≤120 条(单条正文 ≤8000 字符、同时 watch ≤4 个会话)/ 待决授权 ≤4
|
|
333
|
+
对话流每会话 ≤120 条(单条正文 ≤8000 字符、同时 watch ≤4 个会话)/ 待决授权 ≤4 条 /
|
|
334
|
+
写前原文快照 ≤8 份、单份 ≤1MB、总量 ≤4MB、5 分钟过期(见 `/old`)。
|
|
322
335
|
|
|
323
336
|
这一层挡的是"本机其它应用或浏览器页面拿到那个文件后乱调桥";**同用户的本地恶意程序**
|
|
324
337
|
本来就能直接读你的文件与令牌文件 —— 那不在本插件的威胁模型内(与「回环端口的安全模型」同一句话)。
|
|
@@ -470,8 +483,9 @@ pnpm test # 一次跑完下面全部(scripts/run-all-tests.mj
|
|
|
470
483
|
# ↑ 是唯一清单:新增回归脚本只改 scripts/run-all-tests.mjs,CI/README 都跟着它走
|
|
471
484
|
pnpm test:apply # 桩 ctx 下跑通 apply(回归:apply 期的 ReferenceError)
|
|
472
485
|
pnpm test:claim-types # 认领类型语法与默认值
|
|
473
|
-
pnpm test:bridge-routes # 编辑器桥:路由表白名单(只读 + /approve)/ Origin 与令牌的判定顺序 / 令牌头三处一致
|
|
474
|
-
pnpm test:
|
|
486
|
+
pnpm test:bridge-routes # 编辑器桥:路由表白名单(只读 + /approve + /old)/ Origin 与令牌的判定顺序 / 令牌头三处一致
|
|
487
|
+
pnpm test:edit-snapshot # 写前原文快照:从 tools/post-execute 的 value 取完整 before / 路径按会话 cwd 绝对化 / 缓存三重有界 / /old 的 400-404-200
|
|
488
|
+
pnpm test:bridge-extension # 编辑器桥扩展侧纯逻辑:未保存缓冲区上报、诊断排序截断、diff 判据(old 侧优先级)、投递降级、面板状态机
|
|
475
489
|
pnpm test:webview # 面板 webview 产物:官方渲染器与令牌打包、版本一致、/approve 的四条约束(先跑 build:webview)
|
|
476
490
|
pnpm test:launcher-routes # launcher 的 HTTP 面(起真进程,较慢)
|
|
477
491
|
pnpm test:workspace-switch # 切工作区不重启进程
|
|
@@ -483,6 +497,8 @@ pnpm test:vendored # 重打包表 ↔ 插件依赖表一致(无 npm:
|
|
|
483
497
|
pnpm test:dsh-resolve # 部署位置表:各平台全局装布局(npm --prefix / nvm / pnpm global / %APPDATA%)都能找到 DSH 部署
|
|
484
498
|
pnpm test:child-node # Electron 宿主(桌面版)下给 IDE 子进程挑真 Node:候选顺序 / 剥离 ELECTRON_RUN_AS_NODE / 找不到时如实退回
|
|
485
499
|
pnpm test:package-files # 发布物白名单守卫:files 里的模块**递归**import 到的本地文件也必须在 files 里(0.3.53 漏 lib/child-node.mjs 的事故)
|
|
500
|
+
# 以及反向:files 每条都得存在 —— **打包期生成物除外**(vendor/VENDOR.json 在
|
|
501
|
+
# .gitignore 里,由 prepack 的 vendor:vscode 生成;ci.yml 不下树,干净克隆上它必然不存在)
|
|
486
502
|
pnpm test:installed # 安装冒烟:对**已装进 profile 的产物**做断言(默认 <DSH_HOME>/profiles/web)
|
|
487
503
|
# files 白名单每条都在 / 重打包包在当前平台齐全 / 原生模块无缺失 /
|
|
488
504
|
# 已安装副本能 import / 树在位 —— 仓库回归看不出这一类
|
|
@@ -1040,6 +1056,11 @@ desktop profile 由 `apps/desktop-host` 把 `/api/*` 交给同一个 `createShar
|
|
|
1040
1056
|
- **未保存缓冲区是"上报"而不是"接管"**:agent 仍然通过它自己的 `fs` 工具按磁盘内容编辑。
|
|
1041
1057
|
桥能做的是**在写之前提醒**、**写之后给 diff**、**冲突时告警而不覆盖** ——
|
|
1042
1058
|
它不能替用户决定保存与否(那需要改动 agent 的读路径,不在本版本范围内)。
|
|
1059
|
+
- **diff 的左栏是"写前的磁盘内容"(0.3.55 起)**:值取自 `tools/post-execute` 的 `result.value.before`
|
|
1060
|
+
(`write`/`edit` 都给整份文件文本),所以**文件没在编辑器里打开也能给出完整左栏** ——
|
|
1061
|
+
0.3.54 及以前只有"编辑器缓冲区 / 扩展自己的缓存"两条来源,都没命中时左栏是空文本 + 标题写"没有改动前的内容"。
|
|
1062
|
+
仍然拿不到的情形有两种,标题会如实说明:`str_replace_editor` 这类 output 是纯字符串的工具(没有 `value`),
|
|
1063
|
+
以及写前内容 >1MB(不塞进缓存)。**注意** `value` 是 execution-local:它不进会话日志,宿主重启后旧的 diff 不会重放。
|
|
1043
1064
|
- **提问面板只渲染"新内容"(0.3.22)**:订阅从面板建立那一刻开始,`follow` 开帧里的历史 `records` 被丢弃,
|
|
1044
1065
|
面板里**没有"加载更早"**(历史分页 API `sessionController.page()` 在这个版本里刻意不调用)。
|
|
1045
1066
|
想看更早的内容请回 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
|
-
*
|
|
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)
|
|
238
|
-
let
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
//
|
|
341
|
+
// 6) 脏缓冲区:只告警,绝不覆盖。
|
|
314
342
|
if (dirty === true) {
|
|
315
343
|
const choice = await vscode.window.showWarningMessage(
|
|
316
344
|
`${path.basename(target)} 在编辑器里有未保存的改动,而 DSH 刚改了磁盘上的同名文件。`,
|
|
@@ -279,6 +279,12 @@ function createClient(options) {
|
|
|
279
279
|
for (const event of events) {
|
|
280
280
|
if (Number.isSafeInteger(event.seq) && event.seq > since) since = event.seq;
|
|
281
281
|
}
|
|
282
|
+
// 自查:宿主报的 seq 高水位若**低于**我的游标,说明两边序号错位了(宿主重启过,或我手里这份
|
|
283
|
+
// 游标是上一任宿主留下的 —— adopt 场景里 IDE 的 pid 没变,restore() 就会把旧游标装回来)。
|
|
284
|
+
// 不对齐的话 `seq > since` 永远为假 ⇒ 事件永久收不到(0.3.55 及以前的宿主还每趟把 seq 归零,
|
|
285
|
+
// 症状就是"每个 IDE 会话只收到第一条 diff")。退回 0 重新对齐即可自愈。
|
|
286
|
+
const lastSeq = body !== null && Number.isSafeInteger(body.lastSeq) ? body.lastSeq : null;
|
|
287
|
+
if (lastSeq !== null && lastSeq < since) since = 0;
|
|
282
288
|
// 0.2.3 起面板像 DSH 对话一样显示内容,数据走这三个字段(见 host 侧 lib/bridge-thread.mjs
|
|
283
289
|
// 与 lib/bridge-approval.mjs):
|
|
284
290
|
// - thread:被面板观看的会话的**新内容**条目(旧版宿主没有这个字段 → 面板明确报错);
|
|
@@ -334,6 +340,26 @@ function createClient(options) {
|
|
|
334
340
|
body: JSON.stringify({ id, outcome }),
|
|
335
341
|
});
|
|
336
342
|
},
|
|
343
|
+
/**
|
|
344
|
+
* 取一份"写前原文"快照(0.3.55):事件里带的是不透明 key,文本单独取。
|
|
345
|
+
*
|
|
346
|
+
* 为什么不让事件直接带文本:host 每 600ms 的 `/sync` 响应还驮着对话流与待决授权,
|
|
347
|
+
* 塞进 ~1MB 文本会把那一趟拖成超时(超时 ⇒ approvals 一起丢 ⇒ 授权卡片永远不出现)。
|
|
348
|
+
*
|
|
349
|
+
* 取不到(404 = 过期/被淘汰/宿主重启)时**返回 null 而不是抛**:调用方据此回退到缓冲区或缓存。
|
|
350
|
+
*
|
|
351
|
+
* @param {string} key 事件里的 oldKey
|
|
352
|
+
* @returns {Promise<string|null>} 写前原文;没有就给 null
|
|
353
|
+
*/
|
|
354
|
+
async oldText(key) {
|
|
355
|
+
if (typeof key !== 'string' || key === '') return null;
|
|
356
|
+
try {
|
|
357
|
+
const body = await request(`${BRIDGE_BASE}/old?key=${encodeURIComponent(key)}`);
|
|
358
|
+
return body !== null && typeof body.text === 'string' ? body.text : null;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
},
|
|
337
363
|
/** 把游标落盘(实例重启后不重复播报旧事件)。 */
|
|
338
364
|
persist() {
|
|
339
365
|
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/
|
|
8
|
-
// →
|
|
7
|
+
// host: `ctx.on('tools/post-execute')` 拿到这次写的**完整写前原文**(`result.value.before`)
|
|
8
|
+
// → 存进有界快照缓存,`tools/result` 的事件里只带一个不透明 key(见 host 侧 lib/edit-snapshot.mjs)
|
|
9
9
|
// ext : 轮询拿到事件
|
|
10
|
-
// →
|
|
11
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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)
|
|
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.
|
|
5
|
+
"version": "0.2.17",
|
|
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-
|
|
15
|
+
生成时间:2026-09-19T09:45:51.309Z
|
|
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 界面一致)
|
package/lib/bridge-observe.mjs
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/bridge-observe.mjs — 观察 agent 的写操作,并把结果送进编辑器(0.3.0)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 三个观察点,各司其职:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
28
|
-
const
|
|
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
|
-
|
|
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
|
|
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)
|
|
148
|
+
if (previous !== undefined && now - previous < DEBOUNCE_MS) {
|
|
149
|
+
// 去抖丢掉的这条事件,对应的快照也别留着(否则会错配给下一次改动)
|
|
150
|
+
takeSnapshot(filePath);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
105
153
|
lastEmit.set(filePath, now);
|
|
106
|
-
|
|
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) {
|
package/lib/bridge.mjs
CHANGED
|
@@ -252,7 +252,13 @@ export async function callBridge(target, route, options = {}) {
|
|
|
252
252
|
* 事件环形缓冲。host 侧唯一的事件源是 `ctx.on('tools/result')`。
|
|
253
253
|
*
|
|
254
254
|
* 语义:**不是可靠队列**。60/64 条只是让扩展在下一次轮询时"追上",超出即丢最旧的;
|
|
255
|
-
*
|
|
255
|
+
* **seq 在一次桥端点(宿主进程)生命周期内必须单调**,客户端(扩展)的游标只增不减 ——
|
|
256
|
+
* 端点换了(管道名含宿主 pid)客户端用 `since=0` 重新对齐,不承诺断点续传。
|
|
257
|
+
*
|
|
258
|
+
* **0.3.56 修的坑(别再退回去)**:`reset()` 以前把 `nextSeq` 也归零,而 `/sync` 每趟都调用它 ⇒
|
|
259
|
+
* 扩展第一次收到 `seq=1` 后游标就是 1,之后每条新事件又从 1 开始编号,`since(1)` 的
|
|
260
|
+
* `e.seq > 1` 永远为假 ⇒ **每个 IDE 会话最多只送达第一条事件**(0.3.9 起就带着这个缺陷,
|
|
261
|
+
* 用户"只偶尔看到 diff、且那条还是空 old"就是这个现象)。清空缓冲时**不许动 seq**。
|
|
256
262
|
*/
|
|
257
263
|
export function createEventRing(max = EVENT_RING_MAX) {
|
|
258
264
|
/** @type {{seq: number, kind: string, time: number}[]} */
|
|
@@ -280,10 +286,15 @@ export function createEventRing(max = EVENT_RING_MAX) {
|
|
|
280
286
|
size() {
|
|
281
287
|
return items.length;
|
|
282
288
|
},
|
|
283
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* 清空缓冲(**不重置 seq**)。
|
|
291
|
+
*
|
|
292
|
+
* 调用点:`/sync` 每趟取完事件后清空(事件是提示,取过就不必留着 —— 否则客户端重新对齐时
|
|
293
|
+
* 会把一堆陈旧提示重放成一片 diff 窗口),以及 IDE 实例换掉时的 `clearBridgeRuntime`。
|
|
294
|
+
* 两类调用都**不能**让 seq 回退,理由见文件头。
|
|
295
|
+
*/
|
|
284
296
|
reset() {
|
|
285
297
|
items = [];
|
|
286
|
-
nextSeq = 1;
|
|
287
298
|
},
|
|
288
299
|
};
|
|
289
300
|
}
|
|
@@ -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
|
@@ -56,6 +56,7 @@ import { deliverEditorPrompt } from './bridge-session.mjs';
|
|
|
56
56
|
import { dshEntry, dshRequire } from './dsh-resolve.mjs';
|
|
57
57
|
import { childNodeEnv, resolveChildNode } from './child-node.mjs';
|
|
58
58
|
import { registerBridgeObserver } from './bridge-observe.mjs';
|
|
59
|
+
import { createSnapshotStore, snapshotResponse } from './edit-snapshot.mjs';
|
|
59
60
|
import { createThreadRegistry } from './bridge-thread.mjs';
|
|
60
61
|
import { createApprovalBoard, createApprovalInterceptor, DEFAULT_HOLD_MS, PANEL_OUTCOMES } from './bridge-approval.mjs';
|
|
61
62
|
|
|
@@ -733,6 +734,10 @@ export async function apply(ctx, config) {
|
|
|
733
734
|
let bridgeAnswerDispose = null;
|
|
734
735
|
/** 推给编辑器的事件环形缓冲(tools/result → 扩展轮询)。 */
|
|
735
736
|
const bridgeEvents = createEventRing();
|
|
737
|
+
/** 写前原文快照缓存(0.3.55):事件里带不透明 key,扩展走 GET /old 取文本。
|
|
738
|
+
* 有界(条数/字节/存活时长),所以"host 侧不持有文件内容"这条不变量仍然成立 ——
|
|
739
|
+
* 它持有的是最近几次 agent 写操作的写前副本,且随时会被淘汰。 */
|
|
740
|
+
const bridgeSnapshots = createSnapshotStore();
|
|
736
741
|
/** 编辑器状态缓存:扩展在每次 /sync 里推上来,agent 的工具调用来读它。 */
|
|
737
742
|
const bridgeContext = createContextCache();
|
|
738
743
|
/** 被面板观看的会话 → 对话条目流(只渲染新内容;见 lib/bridge-thread.mjs)。 */
|
|
@@ -1084,6 +1089,7 @@ export async function apply(ctx, config) {
|
|
|
1084
1089
|
return [
|
|
1085
1090
|
{ suffix: '/health', methods: ['GET'], fetch: handleBridgeHealth },
|
|
1086
1091
|
{ suffix: '/sync', methods: ['POST'], fetch: handleBridgeSync },
|
|
1092
|
+
{ suffix: '/old', methods: ['GET'], fetch: handleBridgeOld },
|
|
1087
1093
|
{ suffix: '/ask', methods: ['POST'], fetch: handleBridgeAsk },
|
|
1088
1094
|
{ suffix: '/event', methods: ['POST'], fetch: handleBridgeEvent },
|
|
1089
1095
|
{ suffix: '/approve', methods: ['POST'], fetch: handleBridgeApprove },
|
|
@@ -1149,6 +1155,7 @@ export async function apply(ctx, config) {
|
|
|
1149
1155
|
emit: (kind, fields) => bridgeEvents.push(kind, fields),
|
|
1150
1156
|
context: () => bridgeContext.get(),
|
|
1151
1157
|
isLive: () => bridgeMeta !== null && !bridgeContext.isStale(),
|
|
1158
|
+
snapshots: bridgeSnapshots,
|
|
1152
1159
|
});
|
|
1153
1160
|
|
|
1154
1161
|
// 会话流与授权拦截都是**按会话**建立的(在 /ask 成功时 watch + intercept),
|
|
@@ -1237,6 +1244,7 @@ export async function apply(ctx, config) {
|
|
|
1237
1244
|
bridgeToolDispose = disposeSafely(bridgeToolDispose);
|
|
1238
1245
|
bridgeContext.clear();
|
|
1239
1246
|
bridgeEvents.reset();
|
|
1247
|
+
bridgeSnapshots.clear();
|
|
1240
1248
|
if (bridgeMeta === null) return;
|
|
1241
1249
|
bridgeMeta = null;
|
|
1242
1250
|
for (const dir of bridgeConfigDirs()) {
|
|
@@ -1962,6 +1970,29 @@ export async function apply(ctx, config) {
|
|
|
1962
1970
|
});
|
|
1963
1971
|
}
|
|
1964
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
|
+
|
|
1965
1996
|
async function handleBridgeSync(request) {
|
|
1966
1997
|
const denied = bridgeRejection(request);
|
|
1967
1998
|
if (denied !== null) return denied;
|
|
@@ -1983,7 +2014,13 @@ export async function apply(ctx, config) {
|
|
|
1983
2014
|
since = 0;
|
|
1984
2015
|
}
|
|
1985
2016
|
const events = bridgeEvents.since(since);
|
|
2017
|
+
// 取完就清空(事件是提示,取过不必留着 —— 否则客户端重新对齐时会把陈旧提示重放成一片 diff 窗口)。
|
|
2018
|
+
// **注意 `reset()` 不再回退 seq**(0.3.56 修):以前它把 seq 归零,而这里每趟都调 ⇒ 扩展的游标
|
|
2019
|
+
// (单调递增)从此永远大于新 seq,`seq > since` 全被过滤 ⇒ 每个 IDE 会话只送达第一条事件。
|
|
1986
2020
|
bridgeEvents.reset();
|
|
2021
|
+
// 把当前最大 seq 一并回去:客户端据此自查"我的游标是不是跑到宿主前面去了"(跨版本升级、
|
|
2022
|
+
// 接管了别的宿主留下的游标文件等),发现后退回 since=0 重新对齐,免得永久失聪。
|
|
2023
|
+
const lastSeq = bridgeEvents.lastSeq();
|
|
1987
2024
|
// 面板声明它要看哪些会话(数组;空 = 关掉了)。据此对齐订阅 + 判断"面板在不在看"
|
|
1988
2025
|
// (授权拦截只在面板看着的时候才抢答,否则一律交给官方链路)。
|
|
1989
2026
|
const watch = Array.isArray(body.watch) ? body.watch.filter((id) => typeof id === 'string' && id !== '') : [];
|
|
@@ -2031,6 +2068,8 @@ export async function apply(ctx, config) {
|
|
|
2031
2068
|
return jsonResponse({
|
|
2032
2069
|
ok: true,
|
|
2033
2070
|
events,
|
|
2071
|
+
// 事件序号的高水位:扩展用它自查游标是否超前(超前 ⇒ 退回 since=0 重新对齐,避免永久失聪)。
|
|
2072
|
+
lastSeq,
|
|
2034
2073
|
// thread 缺省 = 没变化(扩展沿用上一份);threadRev 用来区分"没变化"与"旧宿主没有这个字段"。
|
|
2035
2074
|
threadRev,
|
|
2036
2075
|
thread: threadChanged ? snapshot : undefined,
|
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); 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.56",
|
|
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. Since 0.3.56 the bridge actually delivers every diff: the event ring's sequence is monotonic for the lifetime of a host process (0.3.9-0.3.55 rewound it after every /sync, so at most one agent-edit per IDE session was ever delivered), /sync returns lastSeq, and the extension re-aligns its cursor by itself when it notices it ran ahead. 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",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"lib/bridge-approval.mjs",
|
|
48
48
|
"lib/dsh-resolve.mjs",
|
|
49
49
|
"lib/child-node.mjs",
|
|
50
|
+
"lib/edit-snapshot.mjs",
|
|
50
51
|
"lib/launcher.mjs",
|
|
51
52
|
"lib/serve-dsh.mjs",
|
|
52
53
|
"lib/vendor.js",
|
|
@@ -196,6 +197,7 @@
|
|
|
196
197
|
"test:child-node": "node scripts/test-child-node.mjs",
|
|
197
198
|
"test:package-files": "node scripts/test-package-files.mjs",
|
|
198
199
|
"test:bridge-routes": "node scripts/test-bridge-routes.mjs",
|
|
200
|
+
"test:edit-snapshot": "node scripts/test-edit-snapshot.mjs",
|
|
199
201
|
"test:bridge-extension": "node scripts/test-bridge-extension.mjs",
|
|
200
202
|
"test:webview": "node scripts/test-webview-bundle.mjs",
|
|
201
203
|
"test:vendored": "node scripts/test-vendored-table.mjs"
|
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-
|
|
6
|
+
"preparedAt": "2026-09-19T09:45:50.744Z",
|
|
7
7
|
"source": "registry",
|
|
8
8
|
"node": "v24.20.0",
|
|
9
9
|
"platform": "linux",
|