dsh-vscode-mode 0.1.7 → 0.1.11

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * dsh-vscode-mode host — 项目级 MCP 管理。
3
+ * 配置以项目根目录 .mcp.json(mcpServers 格式,对齐 Claude Code/Cursor)存储;
4
+ * 工具经 loader 动态激活(entry id 带 PROJECT_ENTRY_PREFIX,全局生效)。
5
+ * 重启后由本项目从各 .mcp.json 恢复激活;.mcp.json 为持久真相,reconcile 以文件为准。
6
+ * 作者 ddj 2026年08月22号
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { PROJECT_ENTRY_PREFIX, entriesOf, serverOf, validateConfig } from './mcp.js'
10
+ import type { MpcConfig, MpcProject, MpcServer } from './shared/mcp.js'
11
+ import type { Ctx } from './store.js'
12
+
13
+ const FILE_NAME = '.mcp.json'
14
+
15
+ /** 校验项目路径属于 DSH 已注册 workspace,避免 RPC 写入任意目录。 */
16
+ function requireWorkspace(ctx: Ctx, workspacePath: string): { path: string; title?: string } {
17
+ const workspace = (ctx.get('workspaceRegistry')?.list?.() ?? []).find((item: { path: string }) => item.path === workspacePath)
18
+ if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目 MCP')
19
+ return workspace
20
+ }
21
+
22
+ /** 用户显式 GUI 管理操作的写策略:danger-full-access(写入路径固定为 workspacePath/.mcp.json)。 */
23
+ function fullPolicy(ctx: Ctx): unknown {
24
+ const svc = ctx.get('sandboxPolicy')
25
+ if (!svc || typeof svc.resolve !== 'function') return undefined
26
+ return svc.resolve({ mode: 'danger-full-access' })
27
+ }
28
+
29
+ /** workspace 路径 → 确定性短哈希(entry id 归属段,重启后稳定可解码)。 */
30
+ export function hashWorkspace(path: string): string {
31
+ return createHash('sha1').update(path).digest('hex').slice(0, 10)
32
+ }
33
+
34
+ /** 项目 MCP 的 loader entry id:vsm-mcp.<pathHash>.<serverName>(loader id 不能用冒号)。 */
35
+ export function projectEntryId(workspacePath: string, serverName: string): string {
36
+ return PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.' + serverName
37
+ }
38
+
39
+ /** 读取项目 .mcp.json 顶层对象(缺失 → 空;非法 → 抛错带文案)。 */
40
+ async function readProjectJson(ctx: Ctx, workspacePath: string): Promise<Record<string, unknown>> {
41
+ const fs = ctx.get('fs')
42
+ if (!fs) throw new Error('缺少 fs 服务')
43
+ const target = await fs.resolve(FILE_NAME, { cwd: workspacePath })
44
+ let text: string
45
+ try {
46
+ text = await fs.readText(target)
47
+ } catch (error) {
48
+ return {}
49
+ }
50
+ let data: unknown
51
+ try {
52
+ data = JSON.parse(text)
53
+ } catch (error) {
54
+ throw new Error('.mcp.json 解析失败:' + String(error))
55
+ }
56
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('.mcp.json 顶层必须是对象')
57
+ return data as Record<string, unknown>
58
+ }
59
+
60
+ /** 读取并校验 .mcp.json 的 mcpServers 映射(纯函数,可单测)。 */
61
+ export function serversOf(data: Record<string, unknown>): Record<string, Record<string, unknown>> {
62
+ const raw = data.mcpServers
63
+ if (raw === undefined) return {}
64
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('.mcp.json 的 mcpServers 必须是对象')
65
+ const out: Record<string, Record<string, unknown>> = {}
66
+ for (const [name, value] of Object.entries(raw)) {
67
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
68
+ out[name] = value as Record<string, unknown>
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null,纯函数可单测)。 */
74
+ export function configFromDef(def: Record<string, unknown> | undefined, serverName: string): MpcConfig | null {
75
+ if (!def) return null
76
+ const transport = typeof def.url === 'string' ? 'streamable-http' : 'stdio'
77
+ const config: MpcConfig = { serverName, transport }
78
+ if (transport === 'stdio') {
79
+ config.command = typeof def.command === 'string' ? def.command : ''
80
+ config.args = Array.isArray(def.args) ? def.args.map(String) : []
81
+ if (typeof def.cwd === 'string') config.cwd = def.cwd
82
+ if (def.env && typeof def.env === 'object') config.env = Object.fromEntries(Object.entries(def.env as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
83
+ } else {
84
+ config.url = def.url as string
85
+ if (def.headers && typeof def.headers === 'object') config.headers = Object.fromEntries(Object.entries(def.headers as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
86
+ }
87
+ if (typeof def.toolCallTimeoutMs === 'number') config.toolCallTimeoutMs = def.toolCallTimeoutMs
88
+ return config
89
+ }
90
+
91
+ /** 写回 .mcp.json(保留顶层未知字段与其余 mcpServers,仅改目标 serverName)。 */
92
+ async function writeProjectJson(ctx: Ctx, workspacePath: string, data: Record<string, unknown>, mcpServers: Record<string, Record<string, unknown>>): Promise<void> {
93
+ const fs = ctx.get('fs')
94
+ if (!fs) throw new Error('缺少 fs 服务')
95
+ const target = await fs.resolve(FILE_NAME, { cwd: workspacePath })
96
+ const next = { ...data, mcpServers }
97
+ await fs.writeText(target, JSON.stringify(next, null, 2) + '\n', void 0, void 0, fullPolicy(ctx))
98
+ }
99
+
100
+ /** 查重:目标 serverName 是否已被其他 loader entry(全局或他项目)占用。 */
101
+ function findConflict(ctx: Ctx, workspacePath: string, serverName: string): string | null {
102
+ const ownId = projectEntryId(workspacePath, serverName)
103
+ for (const entry of entriesOf(ctx)) {
104
+ if (String(entry.id ?? entry.options?.id ?? '') === ownId) continue
105
+ if (entry.options?.config?.serverName === serverName) {
106
+ return 'serverName "' + serverName + '" 已被另一个 MCP 使用(全局或其他项目),请换一个名称'
107
+ }
108
+ }
109
+ return null
110
+ }
111
+
112
+ /** 激活一个项目 MCP:校验 + 查重 + loader.create。 */
113
+ export async function activateProjectMcp(ctx: Ctx, workspacePath: string, config: MpcConfig): Promise<MpcServer> {
114
+ validateConfig(config)
115
+ const conflict = findConflict(ctx, workspacePath, config.serverName)
116
+ if (conflict) throw new Error(conflict)
117
+ const loader = ctx.get('loader')
118
+ if (!loader) throw new Error('缺少 loader 服务')
119
+ const id = projectEntryId(workspacePath, config.serverName)
120
+ const existing = entriesOf(ctx).find((entry) => String(entry.id ?? entry.options?.id ?? '') === id)
121
+ if (existing) {
122
+ await loader.update(existing.id, { config: { ...config } })
123
+ return serverOf(ctx, loader.resolve(existing.id))
124
+ }
125
+ const created = await loader.create({ id, name: '@deepseek-ai/dsh-mcp-client', config: { ...config } })
126
+ const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
127
+ if (!entry) throw new Error('MCP 已创建但未能找到 entry(id=' + id + ')')
128
+ return serverOf(ctx, entry)
129
+ }
130
+
131
+ /** 停用一个项目 MCP(按 entry id)。 */
132
+ export async function deactivateMcp(ctx: Ctx, entryId: string): Promise<void> {
133
+ const loader = ctx.get('loader')
134
+ if (!loader) throw new Error('缺少 loader 服务')
135
+ const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === entryId)
136
+ if (!entry) return
137
+ await loader.remove(entry.id)
138
+ }
139
+
140
+ /** 以 .mcp.json 为真相 reconcile 该项目的激活状态(幂等)。 */
141
+ export async function reconcileProject(ctx: Ctx, workspacePath: string): Promise<void> {
142
+ const data = await readProjectJson(ctx, workspacePath)
143
+ const fileServers = new Set(Object.keys(serversOf(data)))
144
+ const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
145
+ for (const entry of entriesOf(ctx)) {
146
+ const id = String(entry.id ?? entry.options?.id ?? '')
147
+ if (!id.startsWith(prefix)) continue
148
+ const serverName = id.slice(prefix.length)
149
+ if (fileServers.has(serverName)) continue
150
+ await deactivateMcp(ctx, id)
151
+ }
152
+ for (const serverName of fileServers) {
153
+ const config = await configOfServer(ctx, workspacePath, serverName)
154
+ if (config === null) continue
155
+ const id = projectEntryId(workspacePath, serverName)
156
+ if (entriesOf(ctx).some((entry) => String(entry.id ?? entry.options?.id ?? '') === id)) continue
157
+ await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
158
+ }
159
+ }
160
+
161
+ /** 把 .mcp.json 单条 server 定义归一化为 MpcConfig(缺传输必填 → null)。 */
162
+ async function configOfServer(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcConfig | null> {
163
+ const data = await readProjectJson(ctx, workspacePath)
164
+ return configFromDef(serversOf(data)[serverName], serverName)
165
+ }
166
+
167
+ /** 单个项目的完整视图(fileData + 激活状态合并)。 */
168
+ async function projectOf(ctx: Ctx, workspacePath: string, title: string): Promise<MpcProject> {
169
+ const fs = ctx.get('fs')
170
+ if (!fs) return { workspacePath, title, servers: [], source: 'project' }
171
+ try {
172
+ const target = await fs.resolve(workspacePath)
173
+ const info = await fs.stat(target)
174
+ if (!info || info.type !== 'directory') return { workspacePath, title, servers: [], source: 'project', missingDir: true }
175
+ } catch (error) {
176
+ return { workspacePath, title, servers: [], source: 'project', missingDir: true }
177
+ }
178
+ await reconcileProject(ctx, workspacePath)
179
+ let servers: MpcServer[] = []
180
+ let fileError: string | undefined
181
+ try {
182
+ const data = await readProjectJson(ctx, workspacePath)
183
+ const prefix = PROJECT_ENTRY_PREFIX + hashWorkspace(workspacePath) + '.'
184
+ servers = entriesOf(ctx).filter((entry) => String(entry.id ?? entry.options?.id ?? '').startsWith(prefix)).map((entry) => serverOf(ctx, entry))
185
+ servers.sort((a, b) => (a.serverName < b.serverName ? -1 : 1))
186
+ } catch (error) {
187
+ fileError = String(error)
188
+ }
189
+ return { workspacePath, title, servers, source: 'project', fileError }
190
+ }
191
+
192
+ /** 列出全部项目及其项目级 MCP。 */
193
+ export async function listProjects(ctx: Ctx): Promise<{ projects: MpcProject[] }> {
194
+ const registry = ctx.get('workspaceRegistry')
195
+ const workspaces = registry?.list?.() ?? []
196
+ const projects: MpcProject[] = []
197
+ for (const ws of workspaces) projects.push(await projectOf(ctx, ws.path, ws.title ?? ''))
198
+ return { projects }
199
+ }
200
+
201
+ /** 保存(新增/更新)一个项目 MCP:写 .mcp.json → 激活。 */
202
+ export async function projectSave(ctx: Ctx, workspacePath: string, serverName: string, config: MpcConfig): Promise<MpcProject> {
203
+ const workspace = requireWorkspace(ctx, workspacePath)
204
+ validateConfig({ ...config, serverName })
205
+ const conflict = findConflict(ctx, workspacePath, serverName)
206
+ if (conflict) throw new Error(conflict)
207
+ const data = await readProjectJson(ctx, workspacePath)
208
+ const servers = serversOf(data)
209
+ const next = { ...config } as unknown as Record<string, unknown>
210
+ delete next.serverName
211
+ delete next.transport
212
+ delete next.disabled // disabled 由 projectToggle 管理,保存恒为启用
213
+ servers[serverName] = next
214
+ await writeProjectJson(ctx, workspacePath, data, servers)
215
+ await activateProjectMcp(ctx, workspacePath, { ...config, serverName })
216
+ return projectOf(ctx, workspacePath, workspace.title ?? '')
217
+ }
218
+
219
+ /** 删除一个项目 MCP:停用 → 从 .mcp.json 移除。 */
220
+ export async function projectRemove(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
221
+ const workspace = requireWorkspace(ctx, workspacePath)
222
+ await deactivateMcp(ctx, projectEntryId(workspacePath, serverName))
223
+ const data = await readProjectJson(ctx, workspacePath)
224
+ const servers = serversOf(data)
225
+ delete servers[serverName]
226
+ await writeProjectJson(ctx, workspacePath, data, servers)
227
+ return projectOf(ctx, workspacePath, workspace.title ?? '')
228
+ }
229
+
230
+ /** 切换项目 MCP 启用状态(持久化到 .mcp.json + loader.update)。 */
231
+ export async function projectToggle(ctx: Ctx, workspacePath: string, serverName: string, enabled: boolean): Promise<MpcProject> {
232
+ const workspace = requireWorkspace(ctx, workspacePath)
233
+ const data = await readProjectJson(ctx, workspacePath)
234
+ const servers = serversOf(data)
235
+ const def = servers[serverName]
236
+ if (!def) throw new Error('项目中不存在该 MCP')
237
+ if (enabled) delete def.disabled
238
+ else def.disabled = true
239
+ await writeProjectJson(ctx, workspacePath, data, servers)
240
+ const loader = ctx.get('loader')
241
+ if (loader) {
242
+ const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === projectEntryId(workspacePath, serverName))
243
+ if (entry) await loader.update(entry.id, { disabled: !enabled })
244
+ }
245
+ return projectOf(ctx, workspacePath, workspace.title ?? '')
246
+ }
247
+
248
+ /** 刷新(重连)项目 MCP。 */
249
+ export async function projectRefresh(ctx: Ctx, workspacePath: string, serverName: string): Promise<MpcProject> {
250
+ const workspace = requireWorkspace(ctx, workspacePath)
251
+ const loader = ctx.get('loader')
252
+ if (!loader) throw new Error('缺少 loader 服务')
253
+ const id = projectEntryId(workspacePath, serverName)
254
+ const entry = entriesOf(ctx).find((candidate) => String(candidate.id ?? candidate.options?.id ?? '') === id)
255
+ if (!entry) throw new Error('该项目 MCP 未激活')
256
+ await loader.update(entry.id, { config: { ...(entry.options?.config ?? {}) } })
257
+ return projectOf(ctx, workspacePath, workspace.title ?? '')
258
+ }
package/src/model.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import type { Decision, DiffRecord, Hunk, RecordSummary } from './shared/types.js'
7
+ import { applyLocations, fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
7
8
 
8
9
  /** 工作区记录桶上限(超过后按 at 最旧剔除)。 */
9
10
  export const MAX_RECORDS = 200
@@ -19,22 +20,46 @@ export function normalizeRecord(raw: unknown): DiffRecord | null {
19
20
  const r = raw as Record<string, unknown>
20
21
  if (typeof r.callId !== 'string' || typeof r.path !== 'string') return null
21
22
  const hunks: Hunk[] = Array.isArray(r.hunks)
22
- ? (r.hunks as unknown[]).filter((h) => h && typeof (h as { newText?: unknown }).newText === 'string') as Hunk[]
23
+ ? (r.hunks as unknown[]).filter((h) => h && typeof (h as { newText?: unknown }).newText === 'string').map((h) => {
24
+ const item = h as Record<string, unknown>
25
+ return {
26
+ oldText: typeof item.oldText === 'string' ? item.oldText : null,
27
+ newText: item.newText as string,
28
+ ...(Number.isInteger(item.afterStart) ? { afterStart: item.afterStart as number } : {}),
29
+ ...(Number.isInteger(item.afterEnd) ? { afterEnd: item.afterEnd as number } : {}),
30
+ ...(Number.isInteger(item.beforeStart) ? { beforeStart: item.beforeStart as number } : {}),
31
+ ...(Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd as number } : {}),
32
+ }
33
+ })
23
34
  : []
24
35
  const toolName: DiffRecord['toolName'] = r.toolName === 'write' ? 'write' : 'edit'
36
+ const rawDecisions = r.decisions && typeof r.decisions === 'object' ? r.decisions as Record<string, unknown> : {}
37
+ const call = rawDecisions.call === 'accepted' || rawDecisions.call === 'rejected' ? rawDecisions.call : 'pending'
38
+ const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : []
39
+ const perHunk = hunks.map((_, i) => {
40
+ const value = rawPerHunk[i]
41
+ if (value === 'accepted' || value === 'rejected') return value
42
+ return call === 'accepted' || call === 'rejected' ? call : 'pending'
43
+ })
44
+ const before = typeof r.before === 'string' ? r.before : null
45
+ const hasAfter = Object.prototype.hasOwnProperty.call(r, 'after')
46
+ const after = typeof r.after === 'string' ? r.after : null
25
47
  return {
26
48
  callId: r.callId,
27
49
  toolName,
28
50
  path: r.path,
29
- before: typeof r.before === 'string' ? r.before : null,
51
+ before,
52
+ after,
53
+ baseFingerprint: typeof r.baseFingerprint === 'string' ? r.baseFingerprint : fingerprint(before),
54
+ afterFingerprint: typeof r.afterFingerprint === 'string' ? r.afterFingerprint : fingerprint(after),
55
+ legacy: !hasAfter,
56
+ conflict: r.conflict === true,
30
57
  create: r.create === true,
31
- callHunk: r.callHunk && typeof (r.callHunk as { oldText?: unknown }).oldText === 'string'
58
+ callHunk: r.callHunk && typeof (r.callHunk as { oldText?: unknown }).oldText === 'string' && typeof (r.callHunk as { newText?: unknown }).newText === 'string'
32
59
  ? { oldText: (r.callHunk as { oldText: string }).oldText, newText: (r.callHunk as { newText: string }).newText }
33
60
  : null,
34
- hunks: hunks.map((h) => ({ oldText: typeof h.oldText === 'string' ? h.oldText : null, newText: h.newText })),
35
- decisions: r.decisions && typeof r.decisions === 'object'
36
- ? r.decisions as DiffRecord['decisions']
37
- : { call: 'pending', perHunk: hunks.map(() => 'pending' as Decision) },
61
+ hunks,
62
+ decisions: { call: call as Decision, perHunk },
38
63
  note: typeof r.note === 'string' ? r.note : null,
39
64
  superseded: r.superseded === true,
40
65
  archived: r.archived === true,
@@ -68,9 +93,17 @@ export function markDecision(record: DiffRecord, scope: string, idx: number | un
68
93
  /** 记录是否已无任何待决策差异(superseded 或全部已决策)。 */
69
94
  export function recordResolved(record: DiffRecord): boolean {
70
95
  if (record.superseded === true) return true
96
+ const hunks = Array.isArray(record.hunks) ? record.hunks : []
71
97
  const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : []
72
- if (perHunk.length) return perHunk.every((v) => v === 'accepted' || v === 'rejected')
73
- return record.decisions.call === 'accepted' || record.decisions.call === 'rejected'
98
+ const count = Math.max(hunks.length, perHunk.length)
99
+ if (!count) return record.decisions.call === 'accepted' || record.decisions.call === 'rejected'
100
+ for (let idx = 0; idx < count; idx++) {
101
+ const precise = preciseHunk(record, idx)
102
+ const value = perHunk[idx] ?? record.decisions.call
103
+ if (isNoopHunk(precise) && value === 'pending') return false
104
+ if (value !== 'accepted' && value !== 'rejected') return false
105
+ }
106
+ return true
74
107
  }
75
108
 
76
109
  /**
@@ -80,20 +113,20 @@ export function recordResolved(record: DiffRecord): boolean {
80
113
  * @returns 摘要
81
114
  */
82
115
  export function recSummary(record: DiffRecord): RecordSummary {
116
+ const hunks = Array.isArray(record.hunks) ? record.hunks : []
83
117
  const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : []
84
- const n = Math.max(1, Array.isArray(record.hunks) ? record.hunks.length : 1)
118
+ const n = Math.max(1, hunks.length, perHunk.length)
85
119
  let accepted = 0
86
120
  let rejected = 0
87
121
  let pending = 0
88
- if (perHunk.length) {
89
- for (const v of perHunk) {
90
- if (v === 'accepted') accepted++
91
- else if (v === 'rejected') rejected++
92
- else pending++
93
- }
94
- } else if (record.decisions.call === 'accepted') accepted = n
95
- else if (record.decisions.call === 'rejected') rejected = n
96
- else pending = n
122
+ for (let i = 0; i < n; i++) {
123
+ const hunk = preciseHunk(record, i)
124
+ const value = perHunk[i] ?? record.decisions.call
125
+ if (i < hunks.length && isNoopHunk(hunk) && value === 'pending') continue
126
+ if (value === 'accepted') accepted++
127
+ else if (value === 'rejected') rejected++
128
+ else pending++
129
+ }
97
130
  return { accepted, rejected, pending, superseded: record.superseded === true }
98
131
  }
99
132
 
@@ -106,27 +139,21 @@ export function recSummary(record: DiffRecord): RecordSummary {
106
139
  * @returns 重建内容与 stale 定位
107
140
  */
108
141
  export function reconstructOriginal(records: DiffRecord[], content: string): { content: string; stale: Array<{ callId: string; idx: number }> } {
109
- const pending: Array<{ rec: DiffRecord; idx: number }> = []
142
+ const pending: Array<{ rec: DiffRecord; idx: number; hunk: Hunk }> = []
110
143
  for (const rec of records) {
111
- const perHunk = Array.isArray(rec.decisions.perHunk) ? rec.decisions.perHunk : []
112
144
  for (let i = 0; i < rec.hunks.length; i++) {
113
- const st = perHunk.length ? perHunk[i] : rec.decisions.call
114
- if (st === 'pending') pending.push({ rec, idx: i })
145
+ const st = rec.decisions.perHunk[i] ?? rec.decisions.call
146
+ const hunk = preciseHunk(rec, i)
147
+ if (st === 'pending' && hunk && !isNoopHunk(hunk)) pending.push({ rec, idx: i, hunk })
115
148
  }
116
149
  }
117
150
  pending.sort((a, b) => (a.rec.at < b.rec.at ? 1 : a.rec.at > b.rec.at ? -1 : 0))
118
- let out = content
119
- const stale: Array<{ callId: string; idx: number }> = []
120
- for (const p of pending) {
121
- const h = p.rec.hunks[p.idx]
122
- const precise = p.rec.toolName === 'edit' && p.rec.hunks.length <= 1 && p.rec.callHunk ? p.rec.callHunk : h
123
- const newText = precise.newText ?? ''
124
- const at = out.indexOf(newText)
125
- if (at < 0) { stale.push({ callId: p.rec.callId, idx: p.idx }); continue }
126
- const oldText = precise.oldText === null ? '' : precise.oldText
127
- out = out.slice(0, at) + oldText + out.slice(at + newText.length)
151
+ const locations = locateHunks(content, pending.map((item) => item.hunk))
152
+ const rebuilt = applyLocations(content, locations, true)
153
+ return {
154
+ content: rebuilt.content,
155
+ stale: rebuilt.stale.map((idx) => ({ callId: pending[idx].rec.callId, idx: pending[idx].idx })),
128
156
  }
129
- return { content: out, stale }
130
157
  }
131
158
 
132
159
  /** 文件当前最大批次号(无记录为 0)。 */
@@ -188,6 +215,11 @@ export function archiveEntryFor(recs: DiffRecord[], cwd: string, reason: string)
188
215
  decisions: r.decisions,
189
216
  note: r.note ?? null,
190
217
  before: typeof r.before === 'string' ? r.before : null,
218
+ after: typeof r.after === 'string' ? r.after : null,
219
+ baseFingerprint: r.baseFingerprint ?? null,
220
+ afterFingerprint: r.afterFingerprint ?? null,
221
+ conflict: r.conflict === true,
222
+ legacy: r.legacy === true,
191
223
  superseded: r.superseded === true,
192
224
  batch: Number.isInteger(r.batch) ? r.batch : null,
193
225
  at: r.at,
package/src/revert.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type { DiffRecord } from './shared/types.js'
7
7
  import type { Ctx, Session } from './store.js'
8
8
  import { policyOf, resolveTarget } from './store.js'
9
+ import { applyLocations, locateHunks, preciseHunk } from './shared/diff.js'
9
10
 
10
11
  export type Result = { ok: true } | { ok: false; error: string }
11
12
 
@@ -71,12 +72,18 @@ export async function revertCall(ctx: Ctx, session: Session, record: DiffRecord)
71
72
  export async function revertHunk(ctx: Ctx, session: Session, record: DiffRecord, idx: number): Promise<Result> {
72
73
  const fs = ctx.get('fs')
73
74
  if (!fs) return { ok: false, error: '回滚不可用:缺少 fs' }
74
- const hunk = record.hunks[idx]
75
- const precise = record.toolName === 'edit' && record.hunks.length <= 1 && record.callHunk ? record.callHunk : hunk
75
+ const precise = preciseHunk(record, idx)
76
76
  if (!precise) return { ok: false, error: '找不到该差异块' }
77
77
  try {
78
78
  const target = await resolveTarget(ctx, session, record.path)
79
- await fs.editText(target, { oldString: precise.newText, newString: precise.oldText === null ? '' : precise.oldText }, void 0, void 0, policyOf(ctx, session))
79
+ const info = await fs.stat(target)
80
+ if (!info || info.type !== 'file') return { ok: false, error: '回滚失败:文件不存在' }
81
+ if ((info.size ?? 0) > 8 * 1024 * 1024) return { ok: false, error: '回滚失败:文件过大,无法安全定位' }
82
+ const content = await fs.readText(target)
83
+ const location = locateHunks(content, [precise])[0]
84
+ if (!location?.matched) return { ok: false, error: '回滚失败:该区域可能已被后续修改影响' }
85
+ const result = applyLocations(content, [location], true)
86
+ await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session))
80
87
  return { ok: true }
81
88
  } catch (error) {
82
89
  return { ok: false, error: '回滚失败:该区域可能已被后续修改影响(' + String(error) + ')' }
package/src/rpc.ts CHANGED
@@ -23,6 +23,8 @@ import type { Registry } from './registry.js'
23
23
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
24
24
  import { listWorkspaceFiles } from './workspace.js'
25
25
  import { restoreFile, revertCall, revertHunk } from './revert.js'
26
+ import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
27
+ import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
26
28
 
27
29
  /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
28
30
  const debugWriteQueues = new Map<string, Promise<void>>()
@@ -40,6 +42,11 @@ function recView(record: DiffRecord): RecordView {
40
42
  decisions: record.decisions,
41
43
  note: record.note ?? null,
42
44
  superseded: record.superseded === true,
45
+ after: record.after ?? null,
46
+ baseFingerprint: record.baseFingerprint ?? null,
47
+ afterFingerprint: record.afterFingerprint ?? null,
48
+ conflict: record.conflict === true,
49
+ legacy: record.legacy === true,
43
50
  at: record.at,
44
51
  }
45
52
  }
@@ -275,6 +282,43 @@ export function buildHandlers(ctx: Ctx, registry: Registry): RpcHandlerMap {
275
282
  }
276
283
  return { ok: true, files, truncated: files.length >= 50 }
277
284
  },
285
+ 'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
286
+ 'mcp.save': async (args) => {
287
+ try { return { ok: true, server: await saveMcp(ctx, args.config) } }
288
+ catch (error) { return { ok: false, error: String(error) } }
289
+ },
290
+ 'mcp.remove': async (args) => {
291
+ try { await removeMcp(ctx, args.id); return { ok: true } }
292
+ catch (error) { return { ok: false, error: String(error) } }
293
+ },
294
+ 'mcp.toggle': async (args) => {
295
+ try { return { ok: true, server: await toggleMcp(ctx, args.id, args.enabled) } }
296
+ catch (error) { return { ok: false, error: String(error) } }
297
+ },
298
+ 'mcp.refresh': async (args) => {
299
+ try { return { ok: true, server: await refreshMcp(ctx, args.id) } }
300
+ catch (error) { return { ok: false, error: String(error) } }
301
+ },
302
+ 'mcp.projects': async () => {
303
+ try { return { ok: true, ...await listProjects(ctx) } }
304
+ catch (error) { return { ok: false, error: String(error) } }
305
+ },
306
+ 'mcp.projectSave': async (args) => {
307
+ try { return { ok: true, project: await projectSave(ctx, args.workspacePath, args.serverName, args.config) } }
308
+ catch (error) { return { ok: false, error: String(error) } }
309
+ },
310
+ 'mcp.projectRemove': async (args) => {
311
+ try { return { ok: true, project: await projectRemove(ctx, args.workspacePath, args.serverName) } }
312
+ catch (error) { return { ok: false, error: String(error) } }
313
+ },
314
+ 'mcp.projectToggle': async (args) => {
315
+ try { return { ok: true, project: await projectToggle(ctx, args.workspacePath, args.serverName, args.enabled) } }
316
+ catch (error) { return { ok: false, error: String(error) } }
317
+ },
318
+ 'mcp.projectRefresh': async (args) => {
319
+ try { return { ok: true, project: await projectRefresh(ctx, args.workspacePath, args.serverName) } }
320
+ catch (error) { return { ok: false, error: String(error) } }
321
+ },
278
322
  }
279
323
  }
280
324