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.
- package/README.md +12 -0
- package/lib/client.js +725 -89
- package/lib/client.js.map +1 -1
- package/lib/index.js +1003 -94
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +44 -8
- package/src/client/index.ts +11 -2
- package/src/client/monaco/diffRender.ts +17 -2
- package/src/client/monaco/loader.ts +56 -9
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/editor.css +16 -2
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/EditorView.ts +70 -8
- package/src/client/ui/McpSettings.ts +233 -0
- package/src/index.ts +4 -2
- package/src/mcp.ts +123 -0
- package/src/mcpIsolation.ts +160 -0
- package/src/mcpProject.ts +258 -0
- package/src/model.ts +66 -34
- package/src/revert.ts +10 -3
- package/src/rpc.ts +44 -0
- package/src/shared/diff.ts +201 -0
- package/src/shared/mcp.ts +53 -0
- package/src/shared/rpc.ts +21 -0
- package/src/shared/types.ts +15 -0
- package/src/store.ts +29 -31
|
@@ -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')
|
|
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
|
|
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
|
|
35
|
-
decisions:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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.
|
|
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
|
|