dsh-claude-move 0.2.1

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/index.mjs ADDED
@@ -0,0 +1,2891 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // index.mjs — dsh-claude-move host 插件入口。
3
+ //
4
+ // 已注册:claude_scan(F1-F4 + settings 翻译建议 F14)、import_claude(F5-F10/S4/S5)、
5
+ // memory/CLAUDE.md 系统提示词段(F11/F13,同步提供者 + mtime 缓存)、
6
+ // Claude 技能 provider(F12)、/claude-import-all 与 /resume-claude 命令(F15/F17)、
7
+ // /api/claude-move/* 面板 JSON 路由(F16)。
8
+ // 二期(四合一迁移向导):move_detect / move_preview / move_run 工具与 /move 命令
9
+ // (CC/Codex/OpenCode/Hermes 检测→预览→审批执行→报告;move.json 幂等;冲突 diff)。
10
+ //
11
+ // 只消费公开服务:tools / systemPrompt / skills / sessionPersistence /
12
+ // workspaceRegistry / commands / webServer / approval(后几者经 ctx.get 可选读取)。
13
+ // 源文件只读,缓存只写 resolveCacheDir();迁移落点只写 $DSH_HOME/skills、
14
+ // $DSH_HOME/AGENTS.md 与 imports 工作区目录。
15
+
16
+ import { defineTool } from '@deepseek-ai/dsh-tools'
17
+ import Schema from '@deepseek-ai/schemastery'
18
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs'
19
+ import { readFile, mkdir, writeFile } from 'node:fs/promises'
20
+ import { randomUUID } from 'node:crypto'
21
+ import { homedir } from 'node:os'
22
+ import path from 'node:path'
23
+ import {
24
+ INDEX_VERSION,
25
+ DEFAULT_MAX_TRANSCRIPT_BYTES,
26
+ DEFAULT_GIT_TIMEOUT_MS,
27
+ DEFAULT_SCAN_CONCURRENCY,
28
+ locateClaudeHome,
29
+ resolveCacheDir,
30
+ loadCache,
31
+ saveCache,
32
+ loadImports,
33
+ loadImportsSync,
34
+ scanClaudeHome,
35
+ scanProjectDir,
36
+ scanTranscriptFile,
37
+ resetCacheFiles,
38
+ } from './lib/discovery.mjs'
39
+ import { convertClaudeJsonl, convertCodexJsonl, createClaudeStreamConverter, mintSessionId, tailSessionEvents, validateSessionEvents, appendTitleEvent } from './lib/convert.mjs'
40
+ import { scanSecrets, summarizePermissions } from './lib/report.mjs'
41
+ import { makeFileCache, readMemoriesSync, renderMemories, renderClaudeMd, fileExists, selectMemoryDirs, DEFAULT_MEMORY_MAX_BYTES, DEFAULT_MEMORY_SCOPE } from './lib/context.mjs'
42
+ import { makeClaudeSkillsProvider } from './lib/skills-provider.mjs'
43
+ import { translateSettings } from './lib/settings.mjs'
44
+ import { buildHandoff, DEFAULT_HANDOFF_MAX_CHARS } from './lib/handoff.mjs'
45
+ import { importsStore } from './lib/imports-store.mjs'
46
+ import { manifestStore } from './lib/manifest.mjs'
47
+ import { runPreview, runExecute, runWizard, reportLines } from './lib/wizard.mjs'
48
+ import { defaultAgentsMdPath } from './lib/agmd-section.mjs'
49
+ import { personaParagraph, personaSentence } from './lib/persona.mjs'
50
+ import * as claudeParser from './lib/sources/claude/parser.mjs'
51
+ import * as claudeMapper from './lib/sources/claude/mapper.mjs'
52
+ import * as codexParser from './lib/sources/codex/parser.mjs'
53
+ import * as codexMapper from './lib/sources/codex/mapper.mjs'
54
+ import * as opencodeParser from './lib/sources/opencode/parser.mjs'
55
+ import * as opencodeMapper from './lib/sources/opencode/mapper.mjs'
56
+ import * as hermesParser from './lib/sources/hermes/parser.mjs'
57
+ import * as hermesMapper from './lib/sources/hermes/mapper.mjs'
58
+ import { convertOpencodeRows, loadDbSessionRows, loadLegacySessionRows } from './lib/sources/opencode/convert.mjs'
59
+ import { mergeDetections } from './lib/sources/contract.mjs'
60
+
61
+ export const name = 'claude-move'
62
+
63
+ export const inject = ['tools']
64
+
65
+ /** 批量导入「读取 + 转换」阶段的默认并发上限(落盘阶段保持串行,保证幂等确定性)。 */
66
+ export const DEFAULT_IMPORT_CONCURRENCY = 4
67
+
68
+ /**
69
+ * 插件配置(cordis.yml 可覆盖,C4)。
70
+ * @typedef {object} Config
71
+ * @property {string} [claudeHome] Claude 数据根目录;缺省自动定位($CLAUDE_CONFIG_DIR / ~/.claude)。
72
+ * @property {boolean|'branch'} [scanGit] git 探测级别:true 全量(分支复用 transcript gitBranch,只跑 status 算脏行)、'branch' 零 git 子进程(只用 transcript 字段)、false 关闭(默认 true)。
73
+ * @property {number} [gitTimeoutMs] git 子进程超时毫秒(默认 5000)。
74
+ * @property {number} [scanConcurrency] 全量扫描的项目并发上限(默认 8)。
75
+ * @property {number} [maxTranscriptBytes] transcript oversized 判定阈值(默认 64 MiB)。
76
+ * @property {string[]} [excludeProjects] 排除的项目 slug(子串匹配,默认空)。
77
+ * @property {boolean} [enableMemory] 注入 Claude memory 上下文段(默认 true)。
78
+ * @property {number} [memoryMaxBytes] memory 注入字节上限(默认 8192)。
79
+ * @property {'current-project'|'all'} [memoryScope] memory 注入范围:'current-project' 只注入当前会话 cwd 对应项目的记忆(无对应项目时回退全部),'all' 注入全部项目、当前项目优先(默认 'current-project')。
80
+ * @property {boolean} [enableSkills] 注册 Claude 技能 provider(默认 true)。
81
+ * @property {number} [maxSkills] 技能目录条目上限(默认 30)。
82
+ * @property {string[]} [extraSkillDirs] 额外技能目录(默认空)。
83
+ * @property {boolean} [enableInstructions] 注入全局/项目级 CLAUDE.md 段(默认 true)。
84
+ * @property {number} [resumeMaxChars] 续聊交接摘要字符上限(默认 2048)。
85
+ * @property {'inject'|'agents'} [resumeMode] /resume-claude 的继续方式:'inject' 在当前会话注入交接摘要(默认),'agents' 尝试经 ctx.agents.resume 打开导入会话(服务缺失/失败回退注入)。
86
+ * @property {boolean} [enableWebPanel] 注册面板 JSON 路由 /api/claude-move/*(默认 true)。
87
+ * @property {number} [importConcurrency] 批量导入读取+转换并发上限(默认 4;落盘串行)。
88
+ * @property {'claudecode'|'per-project'} [workspaceMode] 工作区归组方式:'claudecode'(默认)把全部导入会话挂到独立的 claudecode 工作区(claudecodeDir 目录);'per-project' 按源项目 cwd 各建一个工作区。
89
+ * @property {string} [claudecodeDir] claudecode 工作区目录;缺省 `$DSH_HOME/claudecode`(DSH_HOME 缺失时 `~/.dsh/claudecode`)。插件只会在此目录下创建文件夹(迁移唯一的有意写入),绝不触碰其它路径。
90
+ * @property {boolean} [enableMove] 注册四合一迁移向导(move_detect/move_preview/move_run 工具与 /move 命令,默认 true)。
91
+ * @property {boolean} [requireApproval] 迁移执行前经 ctx.approval 审批(默认 true;fail-closed,非 allowed-once 零写入;无审批 seam 的平台显式设 false 才可执行)。
92
+ * @property {('claude'|'codex'|'opencode'|'hermes')[]} [sources] 向导覆盖的源(默认四源全开)。
93
+ * @property {string} [codexHome] Codex 数据根;缺省 `$CODEX_HOME` 或 `~/.codex`。
94
+ * @property {string} [opencodeDataHome] OpenCode 数据根;缺省 XDG_DATA_HOME/opencode(平台默认)。
95
+ * @property {string} [opencodeConfigHome] OpenCode 配置根;缺省 XDG_CONFIG_HOME/opencode(平台默认)。
96
+ * @property {string} [hermesHome] Hermes 数据根;缺省 `$HERMES_HOME` 或 `~/.hermes`。
97
+ * @property {'per-source'|'single'} [moveWorkspaceMode] 向导会话归组:'per-source'(默认)每源一个工作区(`$DSH_HOME/imports/<source>`);'single' 全部挂到一个 imports 工作区(`$DSH_HOME/imports`)。
98
+ * @property {string} [skillsDir] 向导技能落点;缺省 `$DSH_HOME/skills`(官方用户技能根,DSH 自动发现)。
99
+ * @property {string} [agentsMdPath] 向导记忆/指令落点;缺省 `$DSH_HOME/AGENTS.md`(DSH 全局指令文件)。
100
+ */
101
+
102
+ export const Config = Schema.object({
103
+ claudeHome: Schema.string(),
104
+ scanGit: Schema.union([Schema.boolean(), Schema.const('branch')]).default(true),
105
+ gitTimeoutMs: Schema.number().default(DEFAULT_GIT_TIMEOUT_MS),
106
+ scanConcurrency: Schema.number().default(DEFAULT_SCAN_CONCURRENCY),
107
+ maxTranscriptBytes: Schema.number().default(DEFAULT_MAX_TRANSCRIPT_BYTES),
108
+ excludeProjects: Schema.array(Schema.string()).default([]),
109
+ enableMemory: Schema.boolean().default(true),
110
+ memoryMaxBytes: Schema.number().default(DEFAULT_MEMORY_MAX_BYTES),
111
+ memoryScope: Schema.union([Schema.const('current-project'), Schema.const('all')]).default(DEFAULT_MEMORY_SCOPE),
112
+ enableSkills: Schema.boolean().default(true),
113
+ maxSkills: Schema.number().default(30),
114
+ extraSkillDirs: Schema.array(Schema.string()).default([]),
115
+ enableInstructions: Schema.boolean().default(true),
116
+ resumeMaxChars: Schema.number().default(DEFAULT_HANDOFF_MAX_CHARS),
117
+ resumeMode: Schema.union([Schema.const('inject'), Schema.const('agents')]).default('inject'),
118
+ enableWebPanel: Schema.boolean().default(true),
119
+ importConcurrency: Schema.number().default(DEFAULT_IMPORT_CONCURRENCY),
120
+ workspaceMode: Schema.union([Schema.const('claudecode'), Schema.const('per-project')]).default('claudecode'),
121
+ claudecodeDir: Schema.string(),
122
+ enableMove: Schema.boolean().default(true),
123
+ requireApproval: Schema.boolean().default(true),
124
+ sources: Schema.array(Schema.union([
125
+ Schema.const('claude'), Schema.const('codex'), Schema.const('opencode'), Schema.const('hermes'),
126
+ ])).default(['claude', 'codex', 'opencode', 'hermes']),
127
+ codexHome: Schema.string(),
128
+ opencodeDataHome: Schema.string(),
129
+ opencodeConfigHome: Schema.string(),
130
+ hermesHome: Schema.string(),
131
+ moveWorkspaceMode: Schema.union([Schema.const('per-source'), Schema.const('single')]).default('per-source'),
132
+ skillsDir: Schema.string(),
133
+ agentsMdPath: Schema.string(),
134
+ })
135
+
136
+ const sessionImportSchema = {
137
+ type: 'object',
138
+ additionalProperties: false,
139
+ properties: {
140
+ status: { type: 'string', required: true },
141
+ dshSessionId: { type: 'string' },
142
+ updatesPending: { type: 'boolean' },
143
+ },
144
+ }
145
+
146
+ const sessionSchema = {
147
+ type: 'object',
148
+ additionalProperties: true,
149
+ properties: {
150
+ file: { type: 'string', required: true },
151
+ sessionId: { type: 'string' },
152
+ title: { type: 'string' },
153
+ messages: { type: 'integer' },
154
+ toolCalls: { type: 'integer' },
155
+ malformed: { type: 'integer' },
156
+ import: sessionImportSchema,
157
+ },
158
+ }
159
+
160
+ const projectSchema = {
161
+ type: 'object',
162
+ additionalProperties: true,
163
+ properties: {
164
+ slug: { type: 'string', required: true },
165
+ sessions: { type: 'array', items: sessionSchema },
166
+ },
167
+ }
168
+
169
+ const scanIndexSchema = {
170
+ type: 'object',
171
+ additionalProperties: true,
172
+ properties: {
173
+ version: { type: 'integer', required: true },
174
+ claudeHome: { type: 'string', required: true },
175
+ scannedAt: { type: 'string', required: true },
176
+ projects: { type: 'array', items: projectSchema, required: true },
177
+ },
178
+ }
179
+
180
+ /**
181
+ * 解析工具参数里的目标路径:'all'/缺省 → 全量;projects 目录/数据根 → 全量;
182
+ * 单个 .jsonl → 单会话;其余目录(projects/<slug> 或任意含 .jsonl 的目录)
183
+ * → 按单个项目扫描。
184
+ * @param raw - 用户给的路径(可含 `~`)。
185
+ * @param claudeHome - 解析出的数据根目录。
186
+ * @returns `{ kind: 'all' }` 或 `{ kind: 'file'|'dir', target }`。
187
+ */
188
+ export function resolveScanTarget(raw, claudeHome) {
189
+ if (typeof raw !== 'string' || raw.trim().length === 0 || raw === 'all') {
190
+ return { kind: 'all' }
191
+ }
192
+ const expanded = raw.startsWith('~')
193
+ ? path.join(homedir(), raw.slice(1).replace(/^[\\/]+/, ''))
194
+ : raw
195
+ const target = path.resolve(expanded)
196
+ const projectsDir = path.join(claudeHome, 'projects')
197
+ if (target === projectsDir || target === claudeHome) return { kind: 'all' }
198
+ if (/\.jsonl$/i.test(target)) return { kind: 'file', target }
199
+ return { kind: 'dir', target }
200
+ }
201
+
202
+ /**
203
+ * 执行一次扫描(按 path 收窄;按 refresh 决定是否复用增量缓存)。
204
+ * @param ctx - Cordis 上下文(仅用于可选导入状态标注)。
205
+ * @param config - 插件配置。
206
+ * @param args - 工具参数 `{ path?, refresh? }`。
207
+ * @param signal - 可选 AbortSignal(工具 exec.signal);中止时抛出 signal.reason。
208
+ * @returns 结构化索引(session.import 已标注)。
209
+ */
210
+ export async function runScan(ctx, config, args, signal) {
211
+ const claudeHome = config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome()
212
+ const cacheDir = resolveCacheDir()
213
+ const scanOpts = {
214
+ maxBytes: config.maxTranscriptBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES,
215
+ gitTimeoutMs: config.gitTimeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
216
+ scanGit: config.scanGit === undefined ? true : config.scanGit,
217
+ concurrency: config.scanConcurrency ?? DEFAULT_SCAN_CONCURRENCY,
218
+ ...(config.excludeProjects?.length ? { excludeProjects: config.excludeProjects } : {}),
219
+ ...(signal ? { signal } : {}),
220
+ }
221
+
222
+ const target = resolveScanTarget(args?.path, claudeHome)
223
+ const cache = args?.refresh === true ? null : await loadCache(cacheDir)
224
+ const shared = { ...scanOpts, ...(cache ? { cache } : {}) }
225
+
226
+ let index
227
+ let files
228
+ if (target.kind === 'all') {
229
+ const result = await scanClaudeHome(claudeHome, shared)
230
+ index = result.index
231
+ files = result.files
232
+ } else if (target.kind === 'file') {
233
+ const session = await scanTranscriptFile(target.target, { maxBytes: scanOpts.maxBytes, ...(signal ? { signal } : {}) })
234
+ const cwd = session.cwd
235
+ index = {
236
+ version: INDEX_VERSION,
237
+ claudeHome,
238
+ scannedAt: new Date().toISOString(),
239
+ projects: [{
240
+ slug: path.basename(path.dirname(target.target)),
241
+ dir: path.dirname(target.target),
242
+ dirExists: typeof cwd === 'string' && existsSync(cwd),
243
+ sessions: [session],
244
+ }],
245
+ personal: null,
246
+ }
247
+ files = session.error ? {} : { [session.file]: session }
248
+ } else {
249
+ const project = await scanProjectDir(target.target, shared)
250
+ index = { version: INDEX_VERSION, claudeHome, scannedAt: new Date().toISOString(), projects: [project], personal: null }
251
+ files = {}
252
+ for (const session of project.sessions) if (!session.error) files[session.file] = session
253
+ }
254
+
255
+ if (target.kind === 'all') {
256
+ await saveCache(cacheDir, { version: INDEX_VERSION, claudeHome, files })
257
+ }
258
+ index.claudeHomeExists = existsSync(claudeHome)
259
+
260
+ await annotateImports(ctx, cacheDir, index, target.kind === 'all')
261
+ await annotateSettings(index)
262
+ trimIndex(index, {
263
+ ...(Number.isInteger(args?.projectsLimit) && args.projectsLimit > 0 ? { projectsLimit: args.projectsLimit } : {}),
264
+ ...(Number.isInteger(args?.sessionsLimit) && args.sessionsLimit > 0 ? { sessionsLimit: args.sessionsLimit } : {}),
265
+ fields: args?.fields === 'brief' ? 'brief' : 'full',
266
+ })
267
+ return index
268
+ }
269
+
270
+ /**
271
+ * 索引裁剪(C4):projectsLimit/sessionsLimit 截断项目与会话(超过时打
272
+ * projectsTruncated/sessionsTruncated 标记);fields='brief' 只保留定位与
273
+ * 导入状态字段,减小模型上下文与 tool/result 日志体量。裁剪发生在缓存
274
+ * 落盘与导入标注之后,不影响增量书签完整性。
275
+ * @param index - 扫描索引(就地裁剪)。
276
+ * @param options - `{ projectsLimit, sessionsLimit, fields }`。
277
+ * @returns 裁剪后的索引。
278
+ */
279
+ export function trimIndex(index, { projectsLimit, sessionsLimit, fields } = {}) {
280
+ let projects = index.projects ?? []
281
+ if (Number.isInteger(projectsLimit) && projectsLimit > 0 && projects.length > projectsLimit) {
282
+ projects = projects.slice(0, projectsLimit)
283
+ index.projectsTruncated = true
284
+ }
285
+ const sl = Number.isInteger(sessionsLimit) && sessionsLimit > 0 ? sessionsLimit : null
286
+ index.projects = projects.map((project) => {
287
+ if (sl === null || (project.sessions ?? []).length <= sl) return project
288
+ return { ...project, sessions: project.sessions.slice(0, sl), sessionsTruncated: true }
289
+ })
290
+ if (fields === 'brief') {
291
+ index.projects = index.projects.map((project) => {
292
+ const brief = { slug: project.slug, dir: project.dir, dirExists: project.dirExists }
293
+ if (project.cwd) brief.cwd = project.cwd
294
+ if (project.git) brief.git = project.git
295
+ if (project.sessionsTruncated) brief.sessionsTruncated = true
296
+ brief.sessions = (project.sessions ?? []).map((s) => ({
297
+ file: s.file,
298
+ sessionId: s.sessionId,
299
+ title: s.title,
300
+ lastActivity: s.lastActivity,
301
+ messages: s.messages,
302
+ toolCalls: s.toolCalls,
303
+ malformed: s.malformed,
304
+ import: s.import,
305
+ }))
306
+ return brief
307
+ })
308
+ }
309
+ return index
310
+ }
311
+
312
+ /**
313
+ * 把全局与项目级 settings.json 翻译为 DSH 配置建议(F14):只建议不代写,
314
+ * 无法映射的键显式列出。读取失败单独记入 errors,不影响扫描。
315
+ * @param index - 扫描索引(就地附加 settingsSuggestions)。
316
+ */
317
+ export async function annotateSettings(index) {
318
+ const files = []
319
+ const globalSettings = index.personal?.settings
320
+ if (globalSettings) files.push(globalSettings.path)
321
+ for (const project of index.projects ?? []) {
322
+ if (project.projectSettings) files.push(project.projectSettings.path)
323
+ }
324
+ const suggestions = []
325
+ const unmapped = new Set()
326
+ const errors = []
327
+ for (const file of files) {
328
+ let raw
329
+ try {
330
+ raw = await readFile(file, 'utf8')
331
+ } catch (err) {
332
+ errors.push(`${file}: ${String((err && err.message) || err)}`)
333
+ continue
334
+ }
335
+ const result = translateSettings(raw, file)
336
+ if (result.error) {
337
+ errors.push(result.error)
338
+ continue
339
+ }
340
+ suggestions.push(...result.suggestions)
341
+ for (const key of result.unmapped) unmapped.add(key)
342
+ }
343
+ index.settingsSuggestions = { suggestions, unmapped: [...unmapped], errors }
344
+ }
345
+
346
+ /**
347
+ * 用 sessionPersistence 列表 + imports 映射标注每个会话的导入状态(F4 幂等基础)。
348
+ * 优先 `listSnapshots()`(更便宜的 header+revision 快照),回退 `list()`。
349
+ * `cleanStale`(全量扫描时)惰性清理「映射指向已不存在会话」的残留记录并
350
+ * 报告清理条数(B4:用户在 UI 删除导入会话后映射不再残留)。
351
+ * @param ctx - Cordis 上下文。
352
+ * @param cacheDir - 缓存目录。
353
+ * @param index - 扫描索引(就地标注)。
354
+ * @param cleanStale - 是否清理失效映射(仅全量扫描时信任快照完整性)。
355
+ */
356
+ export async function annotateImports(ctx, cacheDir, index, cleanStale = false) {
357
+ const imports = await loadImports(cacheDir)
358
+ const sp = ctx.get('sessionPersistence')
359
+ const imported = new Set()
360
+ let listSucceeded = false
361
+ if (sp) {
362
+ try {
363
+ if (typeof sp.listSnapshots === 'function') {
364
+ for (const snap of await sp.listSnapshots()) imported.add(snap.header.id)
365
+ listSucceeded = true
366
+ } else if (typeof sp.list === 'function') {
367
+ for (const header of await sp.list()) imported.add(header.id)
368
+ listSucceeded = true
369
+ }
370
+ } catch {
371
+ // 持久化不可读:全部按未导入处理,也不做清理。
372
+ listSucceeded = false
373
+ }
374
+ }
375
+ for (const project of index.projects ?? []) {
376
+ for (const session of project.sessions ?? []) {
377
+ // 幂等键 = 源文件路径(新格式);sessionId 键保留为旧缓存回退。
378
+ const record = unwrapImport(imports[session.file])
379
+ ?? unwrapImport(imports[session.sessionId])
380
+ const dshId = record?.dshId
381
+ if (dshId && imported.has(dshId)) {
382
+ session.import = { status: 'imported', dshSessionId: dshId }
383
+ // 源文件轮次多于已导入记录 → 面板打「有新增」徽标(D4)。
384
+ if (typeof record.turns === 'number' && typeof session.turns === 'number' && session.turns > record.turns) {
385
+ session.import.updatesPending = true
386
+ }
387
+ } else if (session.error) {
388
+ session.import = { status: 'source-missing' }
389
+ } else {
390
+ session.import = { status: 'none' }
391
+ }
392
+ }
393
+ }
394
+ if (cleanStale && listSucceeded) {
395
+ let cleaned = 0
396
+ for (const key of Object.keys(imports)) {
397
+ const dshId = unwrapImport(imports[key])?.dshId
398
+ if (dshId && !imported.has(dshId)) {
399
+ delete imports[key]
400
+ cleaned++
401
+ }
402
+ }
403
+ if (cleaned > 0) {
404
+ index.importsCleaned = cleaned
405
+ try {
406
+ await importsStore.update((current) => {
407
+ for (const key of Object.keys(current)) {
408
+ const dshId = unwrapImport(current[key])?.dshId
409
+ if (dshId && !imported.has(dshId)) delete current[key]
410
+ }
411
+ })
412
+ } catch (err) {
413
+ console.error('[claude-move] imports cleanup failed:', String((err && err.message) || err))
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ /** claude_scan 结果的模型可读摘要(中文)。 */
420
+ export function renderScan(args, value) {
421
+ const projects = value.projects ?? []
422
+ const sessions = projects.flatMap((p) => p.sessions ?? [])
423
+ const imported = sessions.filter((s) => s.import?.status === 'imported').length
424
+ const skills = value.personal?.skills ?? []
425
+ const lines = []
426
+ lines.push(`已扫描 Claude 根目录 ${value.claudeHome}${value.claudeHomeExists ? '' : '(不存在)'}:`)
427
+ lines.push(`- 项目 ${projects.length} 个、会话 ${sessions.length} 个(已导入 ${imported} 个)、技能 ${skills.length} 个`)
428
+ const malformedTotal = sessions.reduce((sum, s) => sum + (s.malformed ?? 0), 0)
429
+ if (malformedTotal > 0) lines.push(`- 畸形 JSONL 行 ${malformedTotal} 条(导入时逐条报告行号)`)
430
+ if (typeof value.importsCleaned === 'number' && value.importsCleaned > 0) {
431
+ lines.push(`- 清理了 ${value.importsCleaned} 条失效导入映射(对应 DSH 会话已被删除)`)
432
+ }
433
+ if (typeof value.removedBookmarks === 'number' && value.removedBookmarks > 0) {
434
+ lines.push(`- ${value.removedBookmarks} 个源 transcript 已删除(书签随扫描清理)`)
435
+ }
436
+ if (value.projectsTruncated || (value.projects ?? []).some((p) => p.sessionsTruncated)) {
437
+ lines.push('- 索引已按 projectsLimit/sessionsLimit 裁剪(更多内容请调大上限或 fields=full)')
438
+ }
439
+ const suggestionCount = value.settingsSuggestions?.suggestions?.length ?? 0
440
+ const unmappedCount = value.settingsSuggestions?.unmapped?.length ?? 0
441
+ if (suggestionCount > 0 || unmappedCount > 0) {
442
+ lines.push(`- settings.json 翻译建议 ${suggestionCount} 条、无法映射项 ${unmappedCount} 条(见 settingsSuggestions)`)
443
+ }
444
+ const recent = projects.slice(0, 5)
445
+ if (recent.length > 0) {
446
+ lines.push('最近活动:')
447
+ for (const project of recent) {
448
+ const latest = project.sessions.find((s) => typeof s.lastActivity === 'number')
449
+ const when = latest?.lastActivity ? new Date(latest.lastActivity).toLocaleString() : '未知'
450
+ const git = project.git
451
+ ? `git ${git.branch ?? '?'}${typeof git.dirtyCount === 'number' ? `(脏 ${git.dirtyCount})` : ''}`
452
+ : project.dirExists ? '非 git' : '目录不存在'
453
+ lines.push(` - ${project.slug}(${when},会话 ${project.sessions.length} 个,${git})`)
454
+ }
455
+ }
456
+ return [{ type: 'text', text: lines.join('\n') }]
457
+ }
458
+
459
+ function makeScanTool(ctx, config, state) {
460
+ return defineTool({
461
+ name: 'claude_scan',
462
+ description:
463
+ 'Scan the local Claude Code data: locate the data root ($CLAUDE_CONFIG_DIR or ~/.claude), index every project/session (title, timestamps, message & tool-call counts), directory & git state, memories, skills, global CLAUDE.md and settings.json. Returns a structured JSON index. path narrows to the projects directory, one project, one .jsonl or any directory containing .jsonl files; refresh=true bypasses the incremental cache; projectsLimit/sessionsLimit/fields trim the output. Use import_claude for imports. ' +
464
+ '(扫描本机 Claude Code 数据:自动定位数据根目录,索引全部项目/会话、目录与 git 状态、记忆/技能/CLAUDE.md/settings.json;path 收窄范围,refresh 全量重扫,projectsLimit/sessionsLimit/fields 裁剪输出。导入请用 import_claude。)',
465
+ parameters: {
466
+ path: {
467
+ type: 'string',
468
+ description: "可选:'all'(默认全量)、'~/.claude/projects'、单个项目目录、单个 .jsonl 文件,或任意含 .jsonl 的目录。",
469
+ },
470
+ refresh: {
471
+ type: 'boolean',
472
+ description: '可选:true 时忽略增量缓存,全量重扫(默认 false)。',
473
+ },
474
+ projectsLimit: {
475
+ type: 'integer',
476
+ description: '可选:最多返回的项目数(按最近活动排序取前 N,默认全量)。',
477
+ },
478
+ sessionsLimit: {
479
+ type: 'integer',
480
+ description: '可选:每个项目最多返回的会话数(默认全量)。',
481
+ },
482
+ fields: {
483
+ type: 'string',
484
+ enum: ['brief', 'full'],
485
+ description: "可选:'brief' 只返回定位与导入状态字段(减小上下文),默认 'full'。",
486
+ },
487
+ },
488
+ output: {
489
+ schema: scanIndexSchema,
490
+ render: renderScan,
491
+ },
492
+ async execute(args, exec) {
493
+ const value = await runScan(ctx, config, args, exec?.signal)
494
+ state?.invalidateSkills?.()
495
+ return value
496
+ },
497
+ })
498
+ }
499
+
500
+ // ── 历史对话导入(F5-F10)────────────────────────────────────────────────────
501
+
502
+ /**
503
+ * 解析导入目标:'all' → projects 根目录;其它路径照常(支持 `~`)。
504
+ * @param raw - 工具参数里的 path。
505
+ * @param claudeHome - Claude 数据根目录。
506
+ * @returns 绝对路径。
507
+ */
508
+ export function resolveImportTarget(raw, claudeHome) {
509
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
510
+ throw new Error('path 必填:单个 .jsonl、目录、"~/.claude/projects" 或 "all"')
511
+ }
512
+ if (raw === 'all') return path.join(claudeHome, 'projects')
513
+ const expanded = raw.startsWith('~')
514
+ ? path.join(homedir(), raw.slice(1).replace(/^[\\/]+/, ''))
515
+ : raw
516
+ return path.resolve(expanded)
517
+ }
518
+
519
+ /** 已持久化会话 id 集合(批量导入一次快照,避免逐文件 O(n) 列表)。 */
520
+ async function listPersistedIds(ctx) {
521
+ const sp = ctx.get('sessionPersistence')
522
+ if (!sp || typeof sp.list !== 'function') return new Set()
523
+ try {
524
+ return new Set((await sp.list()).map((h) => h.id))
525
+ } catch {
526
+ return new Set()
527
+ }
528
+ }
529
+
530
+ /**
531
+ * 强制重导入的新会话 id:`import-<src>-<n>`,n 取现有后缀最大值 +1(F7)。
532
+ * @param persisted - 已持久化会话 id 快照。
533
+ * @param baseId - 原目标 id。
534
+ * @returns 新 id。
535
+ */
536
+ export function mintForceSessionId(persisted, baseId) {
537
+ const prefix = baseId + '-'
538
+ let max = 0
539
+ for (const id of persisted) {
540
+ if (id.startsWith(prefix)) {
541
+ const n = Number(id.slice(prefix.length))
542
+ if (Number.isInteger(n) && n > max) max = n
543
+ }
544
+ }
545
+ return prefix + (max + 1)
546
+ }
547
+
548
+ /**
549
+ * 把导入的会话挂到其 cwd 对应的工作区(否则显示为「未分组」,F9)。
550
+ * 迁移是复制式的:只新建/复用工作区并挂接,绝不移动或删除任何现有内容。
551
+ * @param ctx - Cordis 上下文。
552
+ * @param meta - SessionHeader。
553
+ * @returns `{ attached, reason? }`;目录不存在/无 workspaceRegistry 时 attached=false。
554
+ */
555
+ export async function attachToWorkspace(ctx, meta) {
556
+ if (!meta.cwd) return { attached: false, reason: 'no-cwd' }
557
+ const wr = ctx.get('workspaceRegistry')
558
+ if (!wr || typeof wr.resolveByPath !== 'function') {
559
+ return { attached: false, reason: 'workspace-registry-unavailable' }
560
+ }
561
+ try {
562
+ let ws = await wr.resolveByPath(meta.cwd)
563
+ if (!ws) ws = await wr.create(meta.cwd)
564
+ await ws.attachSession(meta.id)
565
+ return { attached: true }
566
+ } catch (err) {
567
+ console.error('[claude-move] workspace attach failed:', String((err && err.message) || err))
568
+ return { attached: false, reason: String((err && err.message) || err) }
569
+ }
570
+ }
571
+
572
+ /**
573
+ * 工作区归组模式(E2 + 向导):'claudecode'(默认,全部导入会话挂到独立
574
+ * claudecode 工作区)、'per-project'(按源项目 cwd 各建工作区)或 'wizard'
575
+ * (四合一向导:按 source 挂到 imports 工作区)。未知值一律按 'claudecode'
576
+ * 处理,保持「只新建、不打扰既有目录」的默认安全语义。
577
+ * @param config - 插件配置。
578
+ * @returns 'claudecode' | 'per-project' | 'wizard'。
579
+ */
580
+ export function workspaceModeOf(config) {
581
+ if (config?.workspaceMode === 'per-project') return 'per-project'
582
+ if (config?.workspaceMode === 'wizard') return 'wizard'
583
+ return 'claudecode'
584
+ }
585
+
586
+ /**
587
+ * 向导会话工作区目录:'per-source' → `$DSH_HOME/imports/<source>`;
588
+ * 'single' → `$DSH_HOME/imports`(DSH_HOME 缺失时 `~/.dsh`)。
589
+ * @param config - 插件配置(wizardSource 为源标识)。
590
+ * @param env - 环境对象,缺省 process.env。
591
+ * @returns 目录绝对路径。
592
+ */
593
+ export function resolveMoveDir(config, env = process.env) {
594
+ const base = env.DSH_HOME || path.join(homedir(), '.dsh')
595
+ const source = typeof config?.wizardSource === 'string' && config.wizardSource.length > 0
596
+ ? config.wizardSource
597
+ : 'imports'
598
+ return config?.moveWorkspaceMode === 'single'
599
+ ? path.join(base, 'imports')
600
+ : path.join(base, 'imports', source)
601
+ }
602
+
603
+ /**
604
+ * claudecode 工作区目录:配置显式给出则按绝对路径解析,否则
605
+ * `$DSH_HOME/claudecode`(DSH_HOME 缺失时 `~/.dsh/claudecode`)。
606
+ * @param config - 插件配置。
607
+ * @param env - 环境对象,缺省 process.env。
608
+ * @returns 目录绝对路径。
609
+ */
610
+ export function resolveClaudecodeDir(config, env = process.env) {
611
+ if (typeof config?.claudecodeDir === 'string' && config.claudecodeDir.trim().length > 0) {
612
+ return path.resolve(config.claudecodeDir)
613
+ }
614
+ const base = env.DSH_HOME || path.join(homedir(), '.dsh')
615
+ return path.join(base, 'claudecode')
616
+ }
617
+
618
+ /**
619
+ * 应用工作区 cwd 策略:claudecode 模式下把会话 cwd 覆写为 claudecodeDir
620
+ * (工作区注册表要求 header.cwd 与工作区路径严格相等),并返回覆写前的
621
+ * 源项目 cwd 供 imports 记录保真;per-project 模式不改动。
622
+ * @param meta - SessionHeader(就地修改)。
623
+ * @param config - 插件配置。
624
+ * @returns 覆写前的 cwd(无覆写时返回当前 cwd)。
625
+ */
626
+ export function applyWorkspaceCwd(meta, config) {
627
+ if (!meta) return undefined
628
+ const mode = workspaceModeOf(config)
629
+ if (mode === 'wizard') {
630
+ const original = meta.cwd
631
+ meta.cwd = resolveMoveDir(config)
632
+ return original
633
+ }
634
+ if (mode !== 'claudecode') return meta.cwd
635
+ const original = meta.cwd
636
+ meta.cwd = resolveClaudecodeDir(config)
637
+ return original
638
+ }
639
+
640
+ /**
641
+ * 把导入会话挂到工作区(E2):
642
+ * - per-project:复用 attachToWorkspace(按源 cwd 归组)。
643
+ * - claudecode:确保 claudecodeDir 目录存在(迁移唯一的有意写入,绝不
644
+ * 递归删除任何内容),把该目录注册为标题「claudecode」的工作区并挂接;
645
+ * 标题冲突时依次退回目录名 / 'claude-code' 标题,仍失败则报告原因。
646
+ * @param ctx - Cordis 上下文。
647
+ * @param config - 插件配置。
648
+ * @param meta - SessionHeader(cwd 已按 applyWorkspaceCwd 覆写)。
649
+ * @returns `{ attached, mode, path?, reason? }`。
650
+ */
651
+ export async function attachImportedSession(ctx, config, meta) {
652
+ const mode = workspaceModeOf(config)
653
+ if (mode === 'per-project') {
654
+ const result = await attachToWorkspace(ctx, meta)
655
+ return { ...result, mode: 'per-project' }
656
+ }
657
+ if (mode === 'wizard') {
658
+ // 向导归组:imports/<source>(或 imports)工作区,标题用源标识兜底。
659
+ const dir = resolveMoveDir(config)
660
+ const wr = ctx.get('workspaceRegistry')
661
+ if (!wr || typeof wr.resolveByPath !== 'function' || typeof wr.create !== 'function') {
662
+ return { attached: false, mode: 'wizard', path: dir, reason: 'workspace-registry-unavailable' }
663
+ }
664
+ try {
665
+ await mkdir(dir, { recursive: true })
666
+ let ws = await wr.resolveByPath(dir)
667
+ if (!ws) {
668
+ const sourceTitle = typeof config.wizardSource === 'string' ? config.wizardSource : null
669
+ for (const title of [sourceTitle, undefined, 'imports']) {
670
+ try {
671
+ ws = title === undefined || title === null ? await wr.create(dir) : await wr.create(dir, title)
672
+ break
673
+ } catch (err) {
674
+ const text = String((err && err.name) || '') + ' ' + String((err && err.message) || '')
675
+ if (/conflict/i.test(text)) continue
676
+ throw err
677
+ }
678
+ }
679
+ }
680
+ if (!ws) {
681
+ return { attached: false, mode: 'wizard', path: dir, reason: 'workspace-title-conflict' }
682
+ }
683
+ await ws.attachSession(meta.id)
684
+ return { attached: true, mode: 'wizard', path: dir }
685
+ } catch (err) {
686
+ console.error('[claude-move] workspace attach failed:', String((err && err.message) || err))
687
+ return { attached: false, mode: 'wizard', path: dir, reason: String((err && err.message) || err) }
688
+ }
689
+ }
690
+ const dir = resolveClaudecodeDir(config)
691
+ const wr = ctx.get('workspaceRegistry')
692
+ if (!wr || typeof wr.resolveByPath !== 'function' || typeof wr.create !== 'function') {
693
+ return { attached: false, mode: 'claudecode', path: dir, reason: 'workspace-registry-unavailable' }
694
+ }
695
+ try {
696
+ await mkdir(dir, { recursive: true })
697
+ let ws = await wr.resolveByPath(dir)
698
+ if (!ws) {
699
+ for (const title of ['claudecode', undefined, 'claude-code']) {
700
+ try {
701
+ ws = title === undefined ? await wr.create(dir) : await wr.create(dir, title)
702
+ break
703
+ } catch (err) {
704
+ const text = String((err && err.name) || '') + ' ' + String((err && err.message) || '')
705
+ if (/conflict/i.test(text)) continue
706
+ throw err
707
+ }
708
+ }
709
+ }
710
+ if (!ws) {
711
+ return { attached: false, mode: 'claudecode', path: dir, reason: 'workspace-title-conflict' }
712
+ }
713
+ await ws.attachSession(meta.id)
714
+ return { attached: true, mode: 'claudecode', path: dir }
715
+ } catch (err) {
716
+ console.error('[claude-move] workspace attach failed:', String((err && err.message) || err))
717
+ return { attached: false, mode: 'claudecode', path: dir, reason: String((err && err.message) || err) }
718
+ }
719
+ }
720
+
721
+ /**
722
+ * 记录 源文件路径 → 导入记录(增量缓存目录 imports.json,F4/F7 基础)。
723
+ * 记录形如 `{ dshId, turns, events, sizeBytes, mtimeMs }`:幂等跳过与增量续写
724
+ * 都依赖它;按文件路径为键:多个源文件可能共享同一源 sessionId(Claude
725
+ * 子会话等),按 sessionId 去重会静默丢弃后导入文件的历史。
726
+ * 写入经 importsStore 串行化 + 原子写:与模型工具/命令/面板 job 的并发
727
+ * 读-改-写互不覆盖。
728
+ * @param ctx - Cordis 上下文(保留签名兼容,当前不再读取)。
729
+ * @param key - 源 transcript 绝对路径;缺失则跳过。
730
+ * @param record - `{ dshId, turns, events, sizeBytes, mtimeMs }`。
731
+ */
732
+ export async function rememberImport(ctx, key, record) {
733
+ if (typeof key !== 'string' || key.length === 0) return
734
+ try {
735
+ await importsStore.update((imports) => {
736
+ imports[key] = record
737
+ })
738
+ } catch (err) {
739
+ console.error('[claude-move] remember import failed:', String((err && err.message) || err))
740
+ }
741
+ }
742
+
743
+ /** 兼容旧格式(纯字符串 dshId)读取导入记录。 */
744
+ function unwrapImport(entry) {
745
+ if (typeof entry === 'string') return { dshId: entry }
746
+ if (entry && typeof entry === 'object') return entry
747
+ return null
748
+ }
749
+
750
+ /** 读取已存储日志的事件数(服务支持 readFrom 时);不可用返回 null。 */
751
+ async function storedEventCount(ctx, dshId) {
752
+ const sp = ctx.get('sessionPersistence')
753
+ if (!sp || typeof sp.readFrom !== 'function') return null
754
+ try {
755
+ const read = await sp.readFrom(dshId, 0)
756
+ return Array.isArray(read?.events) ? read.events.length : null
757
+ } catch {
758
+ return null
759
+ }
760
+ }
761
+
762
+ /**
763
+ * 幂等落盘一份已转换会话(F5-F7/F9)。幂等键 = 源文件路径(imports.json)。
764
+ * 复制式语义,绝不删除/改写既有内容:
765
+ * - 首次导入:目标 id 由「显式 sessionId > 源 sessionId > 文件名 slug」确定,
766
+ * 若目标 id 已被占用则后缀避让(import-<src>-<n>),绝不静默丢弃历史;
767
+ * 落盘 = create + append(append-only),随后按 cwd 挂接工作区。
768
+ * - 重复导入且源文件已增长(turns 变多):把新增轮次以连续 seq 续写到同一
769
+ * DSH 会话(增量同步),旧事件一个字节不动。
770
+ * - force:为同一源文件创建一份**新的**完整副本(新 id),旧副本原样保留。
771
+ * 不再归档任何会话——归档会从全部界面隐藏历史,与复制式迁移冲突。
772
+ * @param ctx - Cordis 上下文。
773
+ * @param converted - convertClaudeJsonl 输出。
774
+ * @param args - 工具参数 `{ sessionId?, force? }`。
775
+ * @param persisted - 已持久化 id 快照(就地更新)。
776
+ * @param sourcePath - 源 transcript 绝对路径(幂等键 + 报告用)。
777
+ * @param source - 源文件本次 stat 信息 `{ sizeBytes?, mtimeMs? }`。
778
+ * @param config - 插件配置(工作区归组策略)。
779
+ * @returns 单文件统计。
780
+ */
781
+ export async function persistConverted(ctx, converted, args, persisted, sourcePath, source = {}, config = {}) {
782
+ // 同一源文件并发导入互斥:后到者等先行者落盘后重跑,按幂等路径复用结果
783
+ // (否则 create duplicate 会被记为 failed,且 imports.json 会被并发覆盖)。
784
+ return importsStore.exclusive(sourcePath, () =>
785
+ persistConvertedInner(ctx, converted, args, persisted, sourcePath, source, config))
786
+ }
787
+
788
+ /** 探测同名持久化会话是否为空日志(上次 create 成功、append 失败残留);无法确定返回 false。 */
789
+ async function isEmptyStoredSession(ctx, dshId) {
790
+ const sp = ctx.get('sessionPersistence')
791
+ if (!sp || typeof sp.readFrom !== 'function') return false
792
+ try {
793
+ const read = await sp.readFrom(dshId, 0)
794
+ return Array.isArray(read?.events) && read.events.length === 0
795
+ } catch {
796
+ // 读不到/读失败:按「非空」处理,走保守路径。
797
+ return false
798
+ }
799
+ }
800
+
801
+ async function persistConvertedInner(ctx, converted, args, persisted, sourcePath, source = {}, config = {}) {
802
+ const { meta, events, turns, messages, toolCalls, skipped, skippedLines, typeCounts, repaired, sourceId } = converted
803
+
804
+ // 落盘前自校验(issue#1):不平衡的日志会让会话在续聊时永久 400,宁可
805
+ // 大声失败也不把非法消息流写进持久层(转换器按构造保证通过)。
806
+ const issues = validateSessionEvents(events)
807
+ if (issues.length > 0) {
808
+ throw new Error('claude-move 拒绝落盘:转换结果不满足续聊协议不变式 —— ' + issues.slice(0, 3).join(';'))
809
+ }
810
+
811
+ // 源 sessionId 缺失时用文件名 slug 保证目标 id 跨运行稳定(否则 mintSessionId
812
+ // 回退 Date.now,重复导入不再幂等)。
813
+ if (!args?.sessionId && !sourceId) {
814
+ meta.id = mintSessionId(path.basename(sourcePath).replace(/\.jsonl$/i, ''))
815
+ }
816
+
817
+ // 工作区 cwd 策略:claudecode 模式覆写 cwd,保真记录源项目 cwd(E2)。
818
+ const sourceCwd = applyWorkspaceCwd(meta, config)
819
+
820
+ const base = {
821
+ sessionId: meta.id,
822
+ sourcePath,
823
+ turns: turns.length,
824
+ messages,
825
+ toolCalls,
826
+ skipped,
827
+ skippedLines: skippedLines ?? [],
828
+ permissions: summarizePermissions(typeCounts),
829
+ typeCounts: typeCounts ?? {},
830
+ repaired: repaired ?? { synthesized: 0, duplicateResults: 0, orphanResults: 0 },
831
+ }
832
+
833
+ const cacheDir = resolveCacheDir()
834
+ const imports = await loadImports(cacheDir)
835
+ const known = unwrapImport(imports[sourcePath])
836
+ const knownId = known?.dshId
837
+
838
+ // ── 已导入过:增量续写 / force 新副本 / 幂等跳过 ─────────────────────────
839
+ if (knownId && persisted.has(knownId)) {
840
+ if (args.force === true) {
841
+ // 复制式 force:旧副本原样保留,新建一份完整副本。
842
+ const nextId = mintForceSessionId(persisted, knownId)
843
+ const nextMeta = { ...meta, id: nextId }
844
+ await spPersist(ctx, nextMeta, events)
845
+ persisted.add(nextId)
846
+ const attached = await attachImportedSession(ctx, config, nextMeta)
847
+ await rememberImport(ctx, sourcePath, {
848
+ dshId: nextId, turns: turns.length, events: events.length,
849
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
850
+ ...(typeof sourceCwd === 'string' ? { sourceCwd } : {}),
851
+ })
852
+ return {
853
+ ...base,
854
+ sessionId: nextId,
855
+ status: 'imported',
856
+ workspace: { ...attached, ...(meta.cwd ? { path: meta.cwd } : {}) },
857
+ forceImported: { previous: knownId, current: nextId, archived: false },
858
+ }
859
+ }
860
+
861
+ if (typeof known.turns === 'number' && turns.length > known.turns) {
862
+ // 增量:把源文件新增轮次续写到同一 DSH 会话。
863
+ let fromSeq = typeof known.events === 'number' ? known.events : await storedEventCount(ctx, knownId)
864
+ if (typeof fromSeq !== 'number') {
865
+ // 无法确定存储日志长度:保守跳过,绝不冒险 append 错误 seq。
866
+ return { ...base, sessionId: knownId, status: 'already-imported', appendedSkipped: 'stored-length-unknown' }
867
+ }
868
+ const tail = tailSessionEvents(converted, { fromTurn: known.turns + 1, fromSeq })
869
+ if (tail.events.length > 0) {
870
+ const sp = ctx.get('sessionPersistence')
871
+ if (!sp || typeof sp.append !== 'function') {
872
+ throw new Error('会话持久化服务(sessionPersistence)不可用:claude-move 增量续写需要该服务')
873
+ }
874
+ await sp.append(knownId, tail.events)
875
+ }
876
+ await rememberImport(ctx, sourcePath, {
877
+ dshId: knownId, turns: turns.length, events: fromSeq + tail.events.length,
878
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
879
+ ...(typeof sourceCwd === 'string' ? { sourceCwd } : {}),
880
+ })
881
+ return {
882
+ ...base,
883
+ sessionId: knownId,
884
+ status: 'appended',
885
+ appendedTurns: turns.length - known.turns,
886
+ appendedEvents: tail.events.length,
887
+ }
888
+ }
889
+
890
+ if (typeof known.events === 'number' && events.length > known.events) {
891
+ // 源文件在既有轮次内新增内容(导入时该轮尚未完成):append-only 不能
892
+ // 改写已落盘轮次,保守保留已导入快照;下一轮完成后会按整轮续写。
893
+ return { ...base, sessionId: knownId, status: 'already-imported', alreadyImported: true, changedInPlace: true }
894
+ }
895
+
896
+ return {
897
+ ...base,
898
+ sessionId: knownId,
899
+ status: 'already-imported',
900
+ alreadyImported: true,
901
+ ...(typeof known.turns === 'number' && turns.length < known.turns
902
+ ? { sourceShrunk: true }
903
+ : {}),
904
+ }
905
+ }
906
+
907
+ // ── 首次导入(或源文件从未成功落盘) ─────────────────────────────────────
908
+ if (persisted.has(meta.id)) {
909
+ // 半建残留恢复(A5):同名会话日志为空(上次 create 成功、append 失败)
910
+ // → 复用原 id 直接 append 补全,不另建副本;无法确认时后缀避让。
911
+ if (await isEmptyStoredSession(ctx, meta.id)) {
912
+ await spAppend(ctx, meta.id, events)
913
+ persisted.add(meta.id)
914
+ const attached = await attachImportedSession(ctx, config, meta)
915
+ await rememberImport(ctx, sourcePath, {
916
+ dshId: meta.id, turns: turns.length, events: events.length,
917
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
918
+ ...(typeof sourceCwd === 'string' ? { sourceCwd } : {}),
919
+ })
920
+ return {
921
+ ...base,
922
+ sessionId: meta.id,
923
+ status: 'imported',
924
+ recoveredHalfCreated: true,
925
+ workspace: { ...attached, ...(meta.cwd ? { path: meta.cwd } : {}) },
926
+ }
927
+ }
928
+ // 目标 id 被其它源文件占用(同源 sessionId):后缀避让,保留双方历史。
929
+ meta.id = mintForceSessionId(persisted, meta.id)
930
+ }
931
+ await spPersist(ctx, meta, events)
932
+ persisted.add(meta.id)
933
+ const attached = await attachImportedSession(ctx, config, meta)
934
+ await rememberImport(ctx, sourcePath, {
935
+ dshId: meta.id, turns: turns.length, events: events.length,
936
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
937
+ ...(typeof sourceCwd === 'string' ? { sourceCwd } : {}),
938
+ })
939
+ return {
940
+ ...base,
941
+ sessionId: meta.id,
942
+ status: 'imported',
943
+ workspace: { ...attached, ...(meta.cwd ? { path: meta.cwd } : {}) },
944
+ }
945
+ }
946
+
947
+ /** append 一份事件批次;服务缺失响亮抛出。 */
948
+ async function spAppend(ctx, id, events) {
949
+ const sp = ctx.get('sessionPersistence')
950
+ if (!sp || typeof sp.append !== 'function') {
951
+ throw new Error('会话持久化服务(sessionPersistence)不可用:claude-move 导入需要该服务')
952
+ }
953
+ await sp.append(id, events)
954
+ }
955
+
956
+ /** create + append 一份完整会话日志;服务缺失/落盘失败响亮抛出。 */
957
+ async function spPersist(ctx, meta, events) {
958
+ const sp = ctx.get('sessionPersistence')
959
+ if (!sp || typeof sp.create !== 'function' || typeof sp.append !== 'function') {
960
+ throw new Error('会话持久化服务(sessionPersistence)不可用:claude-move 导入需要该服务')
961
+ }
962
+ await sp.create(meta)
963
+ await sp.append(meta.id, events)
964
+ }
965
+
966
+ /**
967
+ * 获取文件系统服务(可选依赖,经 ctx.get 查询;缺失响亮失败)。
968
+ * Cordis 未声明 inject 的服务不能直接读 ctx.fs 属性("cannot get property
969
+ * without inject"),工具/命令/路由统一走这里。
970
+ * @param ctx - Cordis 上下文。
971
+ * @returns FileSystem 服务。
972
+ */
973
+ export function requireFs(ctx) {
974
+ const fs = ctx.get('fs')
975
+ if (!fs || typeof fs.resolve !== 'function') {
976
+ throw new Error('文件系统服务(ctx.fs)不可用:claude-move 的导入/扫描需要 fs 服务')
977
+ }
978
+ return fs
979
+ }
980
+
981
+ /**
982
+ * 导入单个 transcript(F5-F7/F9/F10):stat → 大小防护 → 读取 → 转换 → 落盘。
983
+ * 超过 maxTranscriptBytes 且 fs 提供 streamText 时走流式分块导入(C3),
984
+ * 内存 O(块) 而非 O(文件);无流式面的环境保持响亮拒绝。
985
+ * @param ctx - Cordis 上下文。
986
+ * @param target - ctx.fs 目标。
987
+ * @param args - 工具参数 `{ sessionId?, force? }`。
988
+ * @param maxBytes - 单文件大小上限。
989
+ * @param persisted - 已持久化 id 快照。
990
+ * @param rawOverride - 已读取的原文(批量路径复用,避免双读)。
991
+ * @param signal - 可选 AbortSignal(工具 exec.signal);中止时抛出 signal.reason。
992
+ * @param config - 插件配置(工作区归组策略)。
993
+ * @returns 单文件统计。
994
+ */
995
+ export async function importTranscript(ctx, target, args, maxBytes, persisted, rawOverride, signal, config = {}) {
996
+ const fs = requireFs(ctx)
997
+ signal?.throwIfAborted()
998
+ const sourcePath = target.displayPath || fs.processPath(target)
999
+ const info = await fs.stat(target)
1000
+ if (info && typeof info.size === 'number' && info.size > maxBytes) {
1001
+ if (rawOverride === undefined && typeof fs.streamText === 'function') {
1002
+ return importsStore.exclusive(sourcePath, () =>
1003
+ importTranscriptStreamed(ctx, fs, target, args, persisted, sourcePath, info, signal, config))
1004
+ }
1005
+ throw new Error(`transcript 过大(${info.size} 字节 > maxTranscriptBytes ${maxBytes}):` +
1006
+ '请调高 maxTranscriptBytes,或改由 /claude-import-all 与面板批量导入(该路径支持流式分块导入超大文件)')
1007
+ }
1008
+ signal?.throwIfAborted()
1009
+ const raw = rawOverride ?? await fs.readText(target)
1010
+ signal?.throwIfAborted()
1011
+ const converted = convertClaudeJsonl(raw, args.sessionId ? { sessionId: args.sessionId } : {})
1012
+ const result = await persistConverted(ctx, converted, args, persisted, sourcePath, {
1013
+ sizeBytes: info && typeof info.size === 'number' ? info.size : undefined,
1014
+ mtimeMs: info && typeof info.mtimeMs === 'number' ? info.mtimeMs : undefined,
1015
+ }, config)
1016
+ result.secrets = scanSecrets(raw)
1017
+ return result
1018
+ }
1019
+
1020
+ /**
1021
+ * 流式分块导入超大 transcript(C3):fs.streamText 逐块读取 → 转换器按回合
1022
+ * 边界分批合成 → 顺序 append(内存 O(当前回合 + 单批))。首次导入的 create
1023
+ * 由首个批次触发(id 冲突时后缀避让);已导入且源增长的按 skipTurns/startSeq
1024
+ * 增量续写同一会话;force 另存新 id 完整副本。中断后重跑天然幂等:imports
1025
+ * 记录只以最终落盘结果为准,重复执行续写从存储长度继续。
1026
+ * @param ctx - Cordis 上下文。
1027
+ * @param fs - FileSystem 服务(已确认含 streamText)。
1028
+ * @param target - ctx.fs 目标。
1029
+ * @param args - 工具参数 `{ sessionId?, force? }`。
1030
+ * @param persisted - 已持久化 id 快照(就地更新)。
1031
+ * @param sourcePath - 源 transcript 绝对路径。
1032
+ * @param info - fs.stat 结果。
1033
+ * @param signal - 可选 AbortSignal。
1034
+ * @param config - 插件配置(工作区归组策略)。
1035
+ * @returns 单文件统计(status: imported | appended | already-imported)。
1036
+ */
1037
+ async function importTranscriptStreamed(ctx, fs, target, args, persisted, sourcePath, info, signal, config = {}) {
1038
+ const imports = await loadImports(resolveCacheDir())
1039
+ const known = unwrapImport(imports[sourcePath])
1040
+ const knownId = known?.dshId
1041
+ let dshId = null
1042
+ let skipTurns = 0
1043
+ let startSeq = 0
1044
+ if (knownId && persisted.has(knownId)) {
1045
+ if (args.force === true) {
1046
+ dshId = mintForceSessionId(persisted, knownId)
1047
+ } else {
1048
+ const se = typeof known.events === 'number' ? known.events : await storedEventCount(ctx, knownId)
1049
+ const st = typeof known.turns === 'number' ? known.turns : 0
1050
+ if (typeof se !== 'number' || st <= 0) {
1051
+ return {
1052
+ sessionId: knownId, status: 'already-imported', appendedSkipped: 'stored-length-unknown',
1053
+ turns: 0, messages: 0, toolCalls: 0, skipped: 0, skippedLines: [],
1054
+ secrets: { total: 0, hits: [] },
1055
+ repaired: { synthesized: 0, duplicateResults: 0, orphanResults: 0 },
1056
+ }
1057
+ }
1058
+ skipTurns = st
1059
+ startSeq = se
1060
+ dshId = knownId
1061
+ }
1062
+ }
1063
+
1064
+ let chain = Promise.resolve()
1065
+ let created = false
1066
+ let firstError = null
1067
+ let originalCwd
1068
+
1069
+ const settleId = () => {
1070
+ const m = converter.meta()
1071
+ if (originalCwd === undefined) originalCwd = m.cwd
1072
+ if (dshId) {
1073
+ m.id = dshId
1074
+ } else if (persisted.has(m.id)) {
1075
+ dshId = mintForceSessionId(persisted, m.id)
1076
+ m.id = dshId
1077
+ }
1078
+ applyWorkspaceCwd(m, config)
1079
+ return m
1080
+ }
1081
+
1082
+ const converter = createClaudeStreamConverter({
1083
+ sessionId: dshId ?? undefined,
1084
+ fallbackSessionId: mintSessionId(path.basename(sourcePath).replace(/\.jsonl$/i, '')),
1085
+ skipTurns,
1086
+ startSeq,
1087
+ batchEvents: 10000,
1088
+ onBatch: (events) => {
1089
+ if (firstError) return
1090
+ // 落盘前自校验(issue#1):批次是完整回合边界,seq 从批次首事件起。
1091
+ const issues = validateSessionEvents(events, events[0]?.seq ?? 0)
1092
+ if (issues.length > 0) {
1093
+ firstError = new Error('claude-move 拒绝落盘:转换结果不满足续聊协议不变式 —— ' + issues.slice(0, 3).join(';'))
1094
+ return
1095
+ }
1096
+ if (skipTurns > 0) {
1097
+ chain = chain.then(() => spAppend(ctx, dshId, events)).catch((err) => { firstError = err })
1098
+ return
1099
+ }
1100
+ if (!created) {
1101
+ const m = settleId()
1102
+ created = true
1103
+ chain = chain
1104
+ .then(() => spPersist(ctx, m, []))
1105
+ .then(() => spAppend(ctx, m.id, events))
1106
+ .catch((err) => { firstError = err })
1107
+ return
1108
+ }
1109
+ chain = chain.then(() => spAppend(ctx, dshId, events)).catch((err) => { firstError = err })
1110
+ },
1111
+ })
1112
+
1113
+ const stream = await fs.streamText(target, signal)
1114
+ const secrets = { total: 0, hits: [] }
1115
+ let lineNo = 0
1116
+ let carryLine = ''
1117
+ const scanLine = (line) => {
1118
+ const hit = scanSecrets(line)
1119
+ if (hit.total <= 0) return
1120
+ secrets.total += hit.total
1121
+ for (const h of hit.hits) {
1122
+ if (secrets.hits.length < 50) secrets.hits.push({ line: lineNo, kind: h.kind })
1123
+ }
1124
+ }
1125
+ for await (const chunk of stream) {
1126
+ signal?.throwIfAborted()
1127
+ const text = carryLine + String(chunk)
1128
+ const lines = text.split('\n')
1129
+ carryLine = lines.pop() ?? ''
1130
+ for (const line of lines) {
1131
+ lineNo++
1132
+ scanLine(line)
1133
+ converter.feed(line + '\n')
1134
+ }
1135
+ }
1136
+ if (carryLine.length > 0) {
1137
+ lineNo++
1138
+ scanLine(carryLine)
1139
+ converter.feed(carryLine + '\n')
1140
+ }
1141
+ const result = converter.end()
1142
+ await chain
1143
+ if (firstError) throw firstError
1144
+ const m = settleId()
1145
+ const source = {
1146
+ sizeBytes: info && typeof info.size === 'number' ? info.size : undefined,
1147
+ mtimeMs: info && typeof info.mtimeMs === 'number' ? info.mtimeMs : undefined,
1148
+ }
1149
+
1150
+ if (skipTurns === 0) {
1151
+ persisted.add(m.id)
1152
+ const attached = await attachImportedSession(ctx, config, m)
1153
+ await rememberImport(ctx, sourcePath, {
1154
+ dshId: m.id, turns: result.turns, events: result.emittedEvents,
1155
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
1156
+ ...(typeof originalCwd === 'string' ? { sourceCwd: originalCwd } : {}),
1157
+ })
1158
+ return {
1159
+ sessionId: m.id,
1160
+ status: 'imported',
1161
+ turns: result.turns,
1162
+ messages: result.messages,
1163
+ toolCalls: result.toolCalls,
1164
+ skipped: result.skipped,
1165
+ skippedLines: result.skippedLines,
1166
+ repaired: result.repaired,
1167
+ secrets,
1168
+ workspace: { ...attached, ...(m.cwd ? { path: m.cwd } : {}) },
1169
+ }
1170
+ }
1171
+ if (result.emittedEvents === 0) {
1172
+ return {
1173
+ sessionId: dshId, status: 'already-imported', alreadyImported: true,
1174
+ turns: skipTurns, messages: 0, toolCalls: 0,
1175
+ skipped: result.skipped, skippedLines: result.skippedLines,
1176
+ repaired: result.repaired, secrets,
1177
+ }
1178
+ }
1179
+ // result.turns 为全文件轮次数(含被跳过前缀);新增轮次 = 总数 - 前缀。
1180
+ const appendedTurns = result.turns - skipTurns
1181
+ await rememberImport(ctx, sourcePath, {
1182
+ dshId, turns: result.turns, events: startSeq + result.emittedEvents,
1183
+ sizeBytes: source.sizeBytes, mtimeMs: source.mtimeMs,
1184
+ ...(typeof originalCwd === 'string' ? { sourceCwd: originalCwd } : {}),
1185
+ })
1186
+ return {
1187
+ sessionId: dshId,
1188
+ status: 'appended',
1189
+ appendedTurns,
1190
+ appendedEvents: result.emittedEvents,
1191
+ turns: result.turns,
1192
+ messages: result.messages,
1193
+ toolCalls: result.toolCalls,
1194
+ skipped: result.skipped,
1195
+ skippedLines: result.skippedLines,
1196
+ repaired: result.repaired,
1197
+ secrets,
1198
+ }
1199
+ }
1200
+
1201
+ /** 递归收集目录下 .jsonl(按路径稳定排序),与上游 chat-import 一致。 */
1202
+ async function collectJsonlFiles(ctx, dirTarget, out, recursive) {
1203
+ const fs = requireFs(ctx)
1204
+ const entries = await fs.listDir(dirTarget)
1205
+ for (const entry of entries) {
1206
+ if (entry.type === 'directory') {
1207
+ if (recursive) await collectJsonlFiles(ctx, entry.target, out, recursive)
1208
+ } else if (entry.type === 'file' && /\.jsonl$/i.test(entry.name)) {
1209
+ out.push(entry.target)
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ /** 有上限的并发执行器:并发跑 `worker`,全部 settle 后返回。 */
1215
+ async function runPool(workerCount, worker) {
1216
+ await Promise.all(Array.from({ length: Math.max(1, workerCount) }, () => worker()))
1217
+ }
1218
+
1219
+ /**
1220
+ * 批量导入(F8):目录下每个 .jsonl 独立导入为会话,逐文件汇总。
1221
+ * 两阶段设计:先按 `concurrency` 并发完成「读取 + 转换」(IO/CPU 密集、
1222
+ * 幂等无关),再按文件名序**串行落盘**(id 后缀避让与 imports.json 映射
1223
+ * 依赖顺序,保证确定性)。任何文件失败只记入结果,不中断批量;
1224
+ * `signal` 中止则整体抛出 signal.reason。
1225
+ * @param ctx - Cordis 上下文。
1226
+ * @param dirTarget - 目录目标。
1227
+ * @param args - 工具参数 `{ recursive?, force? }`。
1228
+ * @param maxBytes - 单文件大小上限。
1229
+ * @param onProgress - 每个文件处理完后的进度回调(面板轮询用),可选。
1230
+ * @param concurrency - 读取+转换并发上限(默认 DEFAULT_IMPORT_CONCURRENCY)。
1231
+ * @param signal - 可选 AbortSignal(工具 exec.signal)。
1232
+ * @param config - 插件配置(工作区归组策略)。
1233
+ * @returns `{ total, imported, alreadyImported, appended, skipped, failed, results }`。
1234
+ */
1235
+ export async function importDirectory(ctx, dirTarget, args, maxBytes, onProgress, concurrency = DEFAULT_IMPORT_CONCURRENCY, signal, config = {}) {
1236
+ const fs = requireFs(ctx)
1237
+ const files = []
1238
+ await collectJsonlFiles(ctx, dirTarget, files, args.recursive !== false)
1239
+ files.sort((a, b) => a.displayPath.localeCompare(b.displayPath))
1240
+ const persisted = await listPersistedIds(ctx)
1241
+ const results = new Array(files.length)
1242
+ let imported = 0
1243
+ let alreadyImported = 0
1244
+ let appended = 0
1245
+ let skipped = 0
1246
+ let failed = 0
1247
+ const notify = () => {
1248
+ if (typeof onProgress === 'function') {
1249
+ onProgress({
1250
+ total: files.length, imported, alreadyImported, appended, skipped, failed,
1251
+ results: results.filter((r) => r !== undefined),
1252
+ })
1253
+ }
1254
+ }
1255
+
1256
+ // 阶段一:并发读取 + 转换。
1257
+ const limit = Number.isInteger(concurrency) && concurrency > 0 ? concurrency : DEFAULT_IMPORT_CONCURRENCY
1258
+ const prepared = new Array(files.length)
1259
+ let cursor = 0
1260
+ await runPool(Math.min(limit, files.length), async () => {
1261
+ for (;;) {
1262
+ const i = cursor++
1263
+ if (i >= files.length) return
1264
+ signal?.throwIfAborted()
1265
+ const target = files[i]
1266
+ const pathLabel = target.displayPath || fs.processPath(target)
1267
+ try {
1268
+ const info = await fs.stat(target)
1269
+ if (info && typeof info.size === 'number' && info.size > maxBytes) {
1270
+ if (typeof fs.streamText === 'function') {
1271
+ // 超大文件:阶段二走流式分块导入(C3),内存 O(块)。
1272
+ prepared[i] = { pathLabel, status: 'streamed' }
1273
+ } else {
1274
+ prepared[i] = {
1275
+ pathLabel, status: 'failed',
1276
+ error: `transcript 过大(${info.size} 字节 > maxTranscriptBytes ${maxBytes})`,
1277
+ }
1278
+ }
1279
+ continue
1280
+ }
1281
+ const raw = await fs.readText(target)
1282
+ signal?.throwIfAborted()
1283
+ const converted = convertClaudeJsonl(raw, {})
1284
+ if (converted.turns.length === 0 && converted.events.length === 0) {
1285
+ prepared[i] = { pathLabel, status: 'skipped', reason: 'not a Claude transcript (no user turns)' }
1286
+ continue
1287
+ }
1288
+ prepared[i] = {
1289
+ pathLabel, raw, converted,
1290
+ source: {
1291
+ sizeBytes: info && typeof info.size === 'number' ? info.size : undefined,
1292
+ mtimeMs: info && typeof info.mtimeMs === 'number' ? info.mtimeMs : undefined,
1293
+ },
1294
+ }
1295
+ } catch (err) {
1296
+ if (signal?.aborted) throw signal.reason ?? err
1297
+ prepared[i] = { pathLabel, status: 'failed', error: String((err && err.message) || err) }
1298
+ }
1299
+ }
1300
+ })
1301
+
1302
+ // 阶段二:按序串行落盘。
1303
+ for (let i = 0; i < files.length; i++) {
1304
+ signal?.throwIfAborted()
1305
+ const p = prepared[i]
1306
+ if (p.status === 'failed') {
1307
+ failed++
1308
+ results[i] = { path: p.pathLabel, status: 'failed', error: p.error }
1309
+ } else if (p.status === 'skipped') {
1310
+ skipped++
1311
+ results[i] = { path: p.pathLabel, status: 'skipped', reason: p.reason }
1312
+ } else if (p.status === 'streamed') {
1313
+ try {
1314
+ const single = await importTranscript(ctx, files[i], { force: args.force }, maxBytes, persisted, undefined, signal, config)
1315
+ if (single.status === 'imported') imported++
1316
+ else if (single.status === 'appended') appended++
1317
+ else alreadyImported++
1318
+ results[i] = { path: p.pathLabel, ...single }
1319
+ } catch (err) {
1320
+ failed++
1321
+ results[i] = { path: p.pathLabel, status: 'failed', error: String((err && err.message) || err) }
1322
+ }
1323
+ } else {
1324
+ try {
1325
+ const single = await persistConverted(ctx, p.converted, { force: args.force }, persisted, p.pathLabel, p.source, config)
1326
+ if (single.status === 'imported') imported++
1327
+ else if (single.status === 'appended') appended++
1328
+ else alreadyImported++
1329
+ results[i] = { path: p.pathLabel, ...single, secrets: scanSecrets(p.raw) }
1330
+ } catch (err) {
1331
+ failed++
1332
+ results[i] = { path: p.pathLabel, status: 'failed', error: String((err && err.message) || err) }
1333
+ }
1334
+ }
1335
+ notify()
1336
+ }
1337
+ return { total: files.length, imported, alreadyImported, appended, skipped, failed, results }
1338
+ }
1339
+
1340
+ const importResultSchema = {
1341
+ type: 'object',
1342
+ additionalProperties: false,
1343
+ properties: {
1344
+ mode: { type: 'string', required: true },
1345
+ sessionId: { type: 'string' },
1346
+ sourcePath: { type: 'string' },
1347
+ turns: { type: 'integer' },
1348
+ messages: { type: 'integer' },
1349
+ toolCalls: { type: 'integer' },
1350
+ skipped: { type: 'integer' },
1351
+ skippedLines: {
1352
+ type: 'array',
1353
+ items: {
1354
+ type: 'object',
1355
+ additionalProperties: false,
1356
+ properties: {
1357
+ line: { type: 'integer', required: true },
1358
+ error: { type: 'string', required: true },
1359
+ },
1360
+ },
1361
+ },
1362
+ secrets: { type: 'object', additionalProperties: true },
1363
+ permissions: { type: 'object', additionalProperties: true },
1364
+ typeCounts: { type: 'object', additionalProperties: true },
1365
+ repaired: { type: 'object', additionalProperties: true },
1366
+ alreadyImported: { type: 'boolean' },
1367
+ status: { type: 'string' },
1368
+ appendedTurns: { type: 'integer' },
1369
+ appendedEvents: { type: 'integer' },
1370
+ appendedSkipped: { type: 'string' },
1371
+ sourceShrunk: { type: 'boolean' },
1372
+ changedInPlace: { type: 'boolean' },
1373
+ recoveredHalfCreated: { type: 'boolean' },
1374
+ workspace: { type: 'object', additionalProperties: true },
1375
+ forceImported: { type: 'object', additionalProperties: true },
1376
+ total: { type: 'integer' },
1377
+ imported: { type: 'integer' },
1378
+ alreadyImported: { type: 'integer' },
1379
+ appended: { type: 'integer' },
1380
+ skipped: { type: 'integer' },
1381
+ failed: { type: 'integer' },
1382
+ results: { type: 'array' },
1383
+ },
1384
+ }
1385
+
1386
+ /** import_claude 结果的模型可读摘要(含畸形行行号与密钥告警,不展示内容)。 */
1387
+ export function renderImport(args, value) {
1388
+ const lines = []
1389
+ if (value.mode === 'batch') {
1390
+ lines.push(`批量导入完成:扫描 ${value.total} 个 .jsonl,`)
1391
+ const bits = []
1392
+ if (value.imported) bits.push(`新增 ${value.imported}`)
1393
+ if (value.appended) bits.push(`增量续写 ${value.appended}`)
1394
+ if (value.alreadyImported) bits.push(`已存在 ${value.alreadyImported}`)
1395
+ if (value.skipped) bits.push(`跳过 ${value.skipped}`)
1396
+ if (value.failed) bits.push(`失败 ${value.failed}`)
1397
+ lines.push(bits.join(',') + '。')
1398
+ for (const r of value.results ?? []) {
1399
+ if (r.status === 'failed') lines.push(`- 失败:${r.path}(${r.error})`)
1400
+ if (r.status === 'appended') lines.push(`- ${r.path} 增量续写 ${r.appendedTurns} 轮(${r.sessionId})`)
1401
+ if (r.sourceShrunk) lines.push(`- ${r.path} 源文件轮次少于已导入记录(可能被重置/截断),需要完整重导请用 force: true。`)
1402
+ if (r.changedInPlace) lines.push(`- ${r.path} 在已导入轮次内新增内容(导入时该轮尚未完成):保留已导入快照,下一轮完成后自动续写;需要当前完整快照请用 force: true。`)
1403
+ if (r.skippedLines?.length) {
1404
+ lines.push(`- ${r.path} 有 ${r.skipped} 行畸形记录,例如第 ${r.skippedLines[0].line} 行:${r.skippedLines[0].error}`)
1405
+ }
1406
+ }
1407
+ } else {
1408
+ if (value.status === 'appended') {
1409
+ lines.push(`会话 ${value.sessionId} 增量续写 ${value.appendedTurns} 轮(累计 ${value.turns} 轮)。`)
1410
+ } else {
1411
+ lines.push(value.alreadyImported
1412
+ ? `会话 ${value.sessionId} 已导入,跳过(${value.turns} 轮、${value.toolCalls} 次工具调用)。` +
1413
+ (args?.force ? '' : ' 需要完整重导请用 force: true(旧副本保留,生成新会话)。')
1414
+ : `已导入 ${value.turns} 轮对话(${value.messages} 条消息、${value.toolCalls} 次工具调用)→ 会话 ${value.sessionId}。`)
1415
+ if (value.sourceShrunk) {
1416
+ lines.push('源文件轮次少于已导入记录(可能被重置/截断);旧副本保留,需要完整重导请用 force: true。')
1417
+ }
1418
+ if (value.changedInPlace) {
1419
+ lines.push('源文件在已导入轮次内新增内容(导入时该轮尚未完成):保留已导入快照,下一轮完成后自动续写;需要当前完整快照请用 force: true。')
1420
+ }
1421
+ if (value.recoveredHalfCreated) {
1422
+ lines.push('检测到上次中断残留的空会话,已复用原 id 补全(未另建副本)。')
1423
+ }
1424
+ }
1425
+ if (value.skipped) {
1426
+ lines.push(`跳过 ${value.skipped} 行畸形记录,明细见 skippedLines(前 ${value.skippedLines?.length ?? 0} 条含行号)。`)
1427
+ }
1428
+ if ((value.typeCounts?.summary ?? 0) > 0) {
1429
+ lines.push(`源记录含 ${value.typeCounts.summary} 条 summary(Claude 上下文压缩摘要,未映射为压缩节点;完整历史已按原始轮次导入)。`)
1430
+ }
1431
+ if (value.workspace && value.workspace.attached === false) {
1432
+ lines.push(`未挂接工作区:${value.workspace.reason ?? '未知原因'}(会话仍已导入,可在会话列表中打开)。`)
1433
+ }
1434
+ }
1435
+ const secretTotal = value.mode === 'batch'
1436
+ ? (value.results ?? []).reduce((n, r) => n + (r.secrets?.total ?? 0), 0)
1437
+ : value.secrets?.total ?? 0
1438
+ if (secretTotal > 0) {
1439
+ lines.push(`⚠️ 检测到 ${secretTotal} 处疑似凭据片段(只报告位置,不展示内容):`)
1440
+ const hits = value.mode === 'batch'
1441
+ ? (value.results ?? []).flatMap((r) => (r.secrets?.hits ?? []).slice(0, 5).map((h) => `${r.path}:${h.line}(${h.kind})`))
1442
+ : (value.secrets?.hits ?? []).slice(0, 5).map((h) => `${h.line}(${h.kind})`)
1443
+ for (const h of hits.slice(0, 5)) lines.push(` - ${h}`)
1444
+ }
1445
+ const summaryTotal = value.mode === 'batch'
1446
+ ? (value.results ?? []).reduce((n, r) => n + (r.typeCounts?.summary ?? 0), 0)
1447
+ : 0
1448
+ if (summaryTotal > 0) {
1449
+ lines.push(`批量源记录共含 ${summaryTotal} 条 summary(Claude 上下文压缩摘要,未映射为压缩节点)。`)
1450
+ }
1451
+ const permTotal = value.mode === 'batch'
1452
+ ? (value.results ?? []).reduce((n, r) => n + (r.permissions?.total ?? 0), 0)
1453
+ : value.permissions?.total ?? 0
1454
+ if (permTotal > 0) {
1455
+ lines.push(`权限类记录 ${permTotal} 条未导入(permission/queue-operation):见报告中的 DSH 权限迁移建议(S5)。`)
1456
+ }
1457
+ const repairedTotal = value.mode === 'batch'
1458
+ ? (value.results ?? []).reduce((n, r) => n + ((r.repaired?.synthesized ?? 0) + (r.repaired?.duplicateResults ?? 0) + (r.repaired?.orphanResults ?? 0)), 0)
1459
+ : ((value.repaired?.synthesized ?? 0) + (value.repaired?.duplicateResults ?? 0) + (value.repaired?.orphanResults ?? 0))
1460
+ if (repairedTotal > 0) {
1461
+ const synth = value.mode === 'batch'
1462
+ ? (value.results ?? []).reduce((n, r) => n + (r.repaired?.synthesized ?? 0), 0)
1463
+ : value.repaired?.synthesized ?? 0
1464
+ lines.push(`工具调用平衡修复:${synth} 处被中断的调用补为合成错误结果,` +
1465
+ `${repairedTotal - synth} 处重复/孤儿结果被丢弃(保证会话可继续对话)。`)
1466
+ }
1467
+ return [{ type: 'text', text: lines.join('\n') }]
1468
+ }
1469
+
1470
+ function makeImportTool(ctx, config) {
1471
+ const maxBytes = config.maxTranscriptBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES
1472
+ return defineTool({
1473
+ name: 'import_claude',
1474
+ description:
1475
+ 'Copy Claude Code JSONL transcripts into resumable DSH sessions. path is a single .jsonl, a directory, "~/.claude/projects" or "all". Full-fidelity user/assistant/tool/thinking mapping, balanced session events, per-cwd workspace attach. Copy-only: never deletes source files and never deletes/rewrites existing DSH sessions. Re-import appends only new turns; force=true saves a fresh full copy under a new id (import-<src>-<n>) and keeps the old copy. Malformed lines carry line numbers, suspected secrets are reported by position only, permission-class records are counted, not imported. Returns a single-file or per-file batch summary. ' +
1476
+ '(从 Claude Code JSONL transcript 复制导入历史对话为可续聊的 DSH 会话;全保真映射、按 cwd 挂接工作区;复制式迁移,绝不删除源文件或改写既有会话;重复导入增量续写,force=true 另存新 id 完整副本;畸形行报行号、密钥只报位置、权限类记录只统计。)',
1477
+ parameters: {
1478
+ path: {
1479
+ type: 'string',
1480
+ required: true,
1481
+ description: "Claude transcript (.jsonl) 路径、目录、'~/.claude/projects' 或 'all'(全量)。",
1482
+ },
1483
+ recursive: {
1484
+ type: 'boolean',
1485
+ description: '可选:目录模式是否递归子目录(默认 true)。',
1486
+ },
1487
+ sessionId: {
1488
+ type: 'string',
1489
+ description: '可选:目标 DSH 会话 id 覆盖(仅单文件;默认 import-<源sessionId>)。',
1490
+ },
1491
+ force: {
1492
+ type: 'boolean',
1493
+ description: '可选:true 时忽略幂等,为该源文件新建一份完整副本(新 id import-<src>-<n>,默认 false)。旧副本与 DSH 既有历史一律保留,绝不归档或删除。',
1494
+ },
1495
+ },
1496
+ output: {
1497
+ schema: importResultSchema,
1498
+ render: renderImport,
1499
+ },
1500
+ async execute(args, exec) {
1501
+ const fs = requireFs(ctx)
1502
+ const claudeHome = config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome()
1503
+ const targetPath = resolveImportTarget(args.path, claudeHome)
1504
+ const target = await fs.resolve(targetPath)
1505
+ exec?.signal?.throwIfAborted()
1506
+ const info = await fs.stat(target)
1507
+ if (info && info.type === 'directory') {
1508
+ const batch = await importDirectory(
1509
+ ctx, target, args, maxBytes, undefined,
1510
+ config.importConcurrency ?? DEFAULT_IMPORT_CONCURRENCY,
1511
+ exec?.signal, config,
1512
+ )
1513
+ return { mode: 'batch', ...batch }
1514
+ }
1515
+ exec?.signal?.throwIfAborted()
1516
+ const persisted = await listPersistedIds(ctx)
1517
+ const single = await importTranscript(ctx, target, args, maxBytes, persisted, undefined, exec?.signal, config)
1518
+ return { mode: 'single', ...single }
1519
+ },
1520
+ })
1521
+ }
1522
+
1523
+ // ── 个人信息搬移(F11-F13):同步注入 + 技能 provider ────────────────────────
1524
+
1525
+ /**
1526
+ * 可选服务就绪即调用:apply 时已存在则立即调用;否则订阅 cordis 的
1527
+ * `internal/service` 事件,服务出现时再调用(避免插件先于服务加载的竞态,
1528
+ * 同时不在 headless 等无该服务的 profile 里保持 PENDING)。
1529
+ * @param ctx - Cordis 上下文。
1530
+ * @param name - 服务名。
1531
+ * @param fn - 服务就绪回调。
1532
+ */
1533
+ export function withService(ctx, name, fn) {
1534
+ const existing = ctx.get(name)
1535
+ if (existing !== undefined && existing !== null) {
1536
+ fn(existing)
1537
+ return
1538
+ }
1539
+ const off = ctx.on('internal/service', (serviceName) => {
1540
+ if (serviceName !== name) return
1541
+ const service = ctx.get(name)
1542
+ if (service !== undefined && service !== null) {
1543
+ off()
1544
+ fn(service)
1545
+ }
1546
+ })
1547
+ }
1548
+
1549
+ /**
1550
+ * 插件状态:Claude 根目录、同步文件缓存、技能目录失效回调。
1551
+ * @param config - 插件配置。
1552
+ * @returns 状态对象(apply 闭包持有)。
1553
+ */
1554
+ export function makeClaudeState(config = {}) {
1555
+ return {
1556
+ config,
1557
+ claudeHome: config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome(),
1558
+ fileCache: makeFileCache(),
1559
+ memoryDirCache: null,
1560
+ indexMapCache: null,
1561
+ sourceCwdCache: null,
1562
+ invalidateSkills: null,
1563
+ registeredCommands: new Set(),
1564
+ lastWizardRun: null,
1565
+ }
1566
+ }
1567
+
1568
+ /**
1569
+ * 同步解析导入会话的源 Claude 项目 cwd(E2 保真映射):claudecode 模式下
1570
+ * 会话 header.cwd 是工作区目录,这里经 imports.json(dshId → sourcePath,
1571
+ * 记录含 sourceCwd 字段时直接用)+ index.json 书签(sourcePath → cwd)找回
1572
+ * 原项目目录。两个缓存文件按 mtime/ctime 失效;缺失/损坏时回退 null(注入层
1573
+ * 按无项目处理)。per-project 模式恒返回 null(header.cwd 即源目录)。
1574
+ * @param state - 插件状态。
1575
+ * @param sessionId - 当前会话 id。
1576
+ * @returns 源项目 cwd 或 null。
1577
+ */
1578
+ export function sourceCwdSync(state, sessionId) {
1579
+ if (!state || workspaceModeOf(state.config) !== 'claudecode') return null
1580
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null
1581
+ const cacheDir = resolveCacheDir()
1582
+ const importsPath = path.join(cacheDir, 'imports.json')
1583
+ const indexPath = path.join(cacheDir, 'index.json')
1584
+ let importsStat = null
1585
+ let indexStat = null
1586
+ try {
1587
+ const st = statSync(importsPath)
1588
+ if (st.isFile()) importsStat = st
1589
+ } catch { /* 无 imports.json */ }
1590
+ try {
1591
+ const st = statSync(indexPath)
1592
+ if (st.isFile()) indexStat = st
1593
+ } catch { /* 无 index.json */ }
1594
+
1595
+ const c = state.sourceCwdCache ?? (state.sourceCwdCache = {})
1596
+ if (!c.byDshId || c.importsMtimeMs !== importsStat?.mtimeMs || c.importsCtimeMs !== importsStat?.ctimeMs) {
1597
+ let imports = {}
1598
+ if (importsStat) {
1599
+ try {
1600
+ const parsed = JSON.parse(readFileSync(importsPath, 'utf8'))
1601
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) imports = parsed
1602
+ } catch { /* 损坏:空映射 */ }
1603
+ }
1604
+ const byDshId = new Map()
1605
+ for (const [sourcePath, entry] of Object.entries(imports)) {
1606
+ const rec = unwrapImport(entry)
1607
+ if (rec && typeof rec.dshId === 'string') {
1608
+ byDshId.set(rec.dshId, { sourcePath, sourceCwd: typeof rec.sourceCwd === 'string' ? rec.sourceCwd : null })
1609
+ }
1610
+ }
1611
+ c.byDshId = byDshId
1612
+ c.importsMtimeMs = importsStat?.mtimeMs
1613
+ c.importsCtimeMs = importsStat?.ctimeMs
1614
+ }
1615
+ const rec = c.byDshId.get(sessionId)
1616
+ if (!rec) return null
1617
+ if (rec.sourceCwd) return rec.sourceCwd
1618
+ // 旧版 imports 记录无 sourceCwd:用 index.json 书签兜底。
1619
+ if (!c.byFile || c.indexMtimeMs !== indexStat?.mtimeMs || c.indexCtimeMs !== indexStat?.ctimeMs) {
1620
+ const byFile = new Map()
1621
+ if (indexStat) {
1622
+ try {
1623
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'))
1624
+ for (const [file, header] of Object.entries(parsed?.files ?? {})) {
1625
+ if (header && typeof header.cwd === 'string') byFile.set(file, header.cwd)
1626
+ }
1627
+ } catch { /* 损坏:无兜底 */ }
1628
+ }
1629
+ c.byFile = byFile
1630
+ c.indexMtimeMs = indexStat?.mtimeMs
1631
+ c.indexCtimeMs = indexStat?.ctimeMs
1632
+ }
1633
+ return c.byFile.get(rec.sourcePath) ?? null
1634
+ }
1635
+
1636
+ /**
1637
+ * 从扫描书签缓存(index.json)定位当前会话 cwd 对应的 memory 目录(B3)。
1638
+ * 书签按 mtime/ctime 缓存,解析出的 cwd→项目目录映射同缓存;无缓存/无
1639
+ * 对应项目返回 null(注入层回退全部目录保底)。Windows 路径大小写不敏感。
1640
+ * @param state - 插件状态。
1641
+ * @param cwd - 当前会话工作目录。
1642
+ * @returns memory 目录绝对路径或 null。
1643
+ */
1644
+ export function cwdMemoryDirSync(state, cwd) {
1645
+ if (!state || typeof cwd !== 'string' || cwd.length === 0) return null
1646
+ const cachePath = path.join(resolveCacheDir(), 'index.json')
1647
+ let st
1648
+ try {
1649
+ st = statSync(cachePath)
1650
+ if (!st.isFile()) return null
1651
+ } catch {
1652
+ // 无书签缓存:返回 null(回退全部目录)。
1653
+ return null
1654
+ }
1655
+ if (!state.indexMapCache
1656
+ || state.indexMapCache.mtimeMs !== st.mtimeMs
1657
+ || state.indexMapCache.ctimeMs !== st.ctimeMs) {
1658
+ let parsed
1659
+ try {
1660
+ parsed = JSON.parse(readFileSync(cachePath, 'utf8'))
1661
+ } catch {
1662
+ // 损坏缓存:按无缓存处理。
1663
+ return null
1664
+ }
1665
+ const map = new Map()
1666
+ for (const [file, header] of Object.entries(parsed?.files ?? {})) {
1667
+ if (header && typeof header.cwd === 'string' && typeof file === 'string') {
1668
+ const key = process.platform === 'win32' ? header.cwd.toLowerCase() : header.cwd
1669
+ if (!map.has(key)) map.set(key, path.dirname(file))
1670
+ }
1671
+ }
1672
+ state.indexMapCache = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, map }
1673
+ }
1674
+ const key = process.platform === 'win32' ? cwd.toLowerCase() : cwd
1675
+ const dir = state.indexMapCache.map.get(key)
1676
+ return dir ? path.join(dir, 'memory') : null
1677
+ }
1678
+
1679
+ /**
1680
+ * 平台感知的路径相等(Windows 大小写不敏感)。
1681
+ * @param a - 路径一。
1682
+ * @param b - 路径二。
1683
+ * @returns boolean。
1684
+ */
1685
+ export function samePath(a, b) {
1686
+ const norm = (x) => path.resolve(x)
1687
+ if (process.platform === 'win32') return norm(a).toLowerCase() === norm(b).toLowerCase()
1688
+ return norm(a) === norm(b)
1689
+ }
1690
+
1691
+ /**
1692
+ * 枚举全部 memory 目录(同步,按 projects 目录 mtime 缓存)。
1693
+ * F11 注入全部项目的 memory 并按类型优先级排序,由字节上限控制总量。
1694
+ * @param state - 插件状态。
1695
+ * @returns memory 目录绝对路径数组。
1696
+ */
1697
+ export function memoryDirsSync(state) {
1698
+ const projectsDir = path.join(state.claudeHome, 'projects')
1699
+ try {
1700
+ const st = statSync(projectsDir)
1701
+ if (state.memoryDirCache && state.memoryDirCache.mtimeMs === st.mtimeMs && state.memoryDirCache.ctimeMs === st.ctimeMs) {
1702
+ return state.memoryDirCache.dirs
1703
+ }
1704
+ const dirs = readdirSync(projectsDir, { withFileTypes: true })
1705
+ .filter((e) => e.isDirectory())
1706
+ .map((e) => path.join(projectsDir, e.name, 'memory'))
1707
+ .filter((d) => fileExists(d))
1708
+ state.memoryDirCache = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, dirs }
1709
+ return dirs
1710
+ } catch {
1711
+ // 无 projects 目录:无记忆。
1712
+ return []
1713
+ }
1714
+ }
1715
+
1716
+ /**
1717
+ * 注册 F11/F12/F13 三组贡献(服务缺失时按可选依赖跳过)。
1718
+ * @param ctx - Cordis 上下文。
1719
+ * @param config - 插件配置。
1720
+ * @param state - 插件状态。
1721
+ */
1722
+ export function registerContextContributions(ctx, config, state) {
1723
+ withService(ctx, 'systemPrompt', (systemPrompt) => {
1724
+ // F11:memory 动态上下文段(同步提供者 + mtime 缓存,每次请求重读变化文件)。
1725
+ if (config.enableMemory !== false && typeof systemPrompt.context === 'function') {
1726
+ systemPrompt.context({
1727
+ name: 'claude-move:memory',
1728
+ order: 120,
1729
+ text: (assemble) => {
1730
+ const header = assemble?.agent?.session?.header
1731
+ const cwd = header?.cwd
1732
+ const dirs = memoryDirsSync(state)
1733
+ // claudecode 模式下 header.cwd 是工作区目录,按 imports/index 找回
1734
+ // 源项目 cwd 做 current-project 匹配;找不到时回退全部项目。
1735
+ const srcCwd = sourceCwdSync(state, header?.id) ?? cwd
1736
+ const currentDir = typeof srcCwd === 'string' && srcCwd.length > 0 ? cwdMemoryDirSync(state, srcCwd) : null
1737
+ const selected = selectMemoryDirs(dirs, currentDir, config.memoryScope ?? DEFAULT_MEMORY_SCOPE)
1738
+ const memories = selected.flatMap((dir) => readMemoriesSync(dir, state.fileCache))
1739
+ return renderMemories(memories, config.memoryMaxBytes ?? DEFAULT_MEMORY_MAX_BYTES)
1740
+ },
1741
+ })
1742
+ }
1743
+
1744
+ // F13:全局 + 项目级 CLAUDE.md(项目优先,前置于 persona)。
1745
+ if (config.enableInstructions !== false && typeof systemPrompt.section === 'function') {
1746
+ systemPrompt.section({
1747
+ name: 'claude-move:instructions',
1748
+ order: -90,
1749
+ text: (assemble) => {
1750
+ const header = assemble?.agent?.session?.header
1751
+ const cwd = header?.cwd
1752
+ const globalPath = path.join(state.claudeHome, 'CLAUDE.md')
1753
+ const globalText = fileExists(globalPath) ? state.fileCache.read(globalPath) : null
1754
+ // 同上:claudecode 模式先找回源项目 cwd 再定位项目级 CLAUDE.md。
1755
+ const srcCwd = sourceCwdSync(state, header?.id) ?? cwd
1756
+ const projectPath = typeof srcCwd === 'string' && srcCwd.length > 0
1757
+ ? path.join(srcCwd, '.claude', 'CLAUDE.md')
1758
+ : null
1759
+ const projectText = projectPath && fileExists(projectPath) ? state.fileCache.read(projectPath) : null
1760
+ return renderClaudeMd(projectText, globalText)
1761
+ },
1762
+ })
1763
+ }
1764
+ })
1765
+
1766
+ // F12:Claude 技能 provider(async list/get;扫描后失效目录缓存)。
1767
+ withService(ctx, 'skills', (skills) => {
1768
+ if (config.enableSkills !== false && typeof skills.registerProvider === 'function') {
1769
+ const roots = [path.join(state.claudeHome, 'skills'), ...(config.extraSkillDirs ?? [])]
1770
+ skills.registerProvider((control) => {
1771
+ state.invalidateSkills = () => control.invalidate()
1772
+ return makeClaudeSkillsProvider({ roots, maxSkills: config.maxSkills ?? 30 })
1773
+ })
1774
+ }
1775
+ })
1776
+ }
1777
+
1778
+ // ── 人机命令(F15/F17)───────────────────────────────────────────────────────
1779
+
1780
+ /**
1781
+ * 把上下文注入当前会话(模型可见 ⟺ 落盘:inject 走 inbox,随日志持久化)。
1782
+ * @param agent - CommandInvocation.agent。
1783
+ * @param text - 注入文本。
1784
+ * @returns 是否注入成功。
1785
+ */
1786
+ export function injectContext(agent, text) {
1787
+ if (!agent || typeof agent.inject !== 'function') return false
1788
+ try {
1789
+ agent.inject({
1790
+ id: 'claude-move:' + randomUUID(),
1791
+ role: 'user',
1792
+ content: [{ type: 'text', text }],
1793
+ source: { kind: 'plugin', plugin: 'claude-move' },
1794
+ })
1795
+ return true
1796
+ } catch {
1797
+ // 会话已销毁等:注入失败不阻断命令结果。
1798
+ return false
1799
+ }
1800
+ }
1801
+
1802
+ /**
1803
+ * 解析 /resume-claude 引用:latest/空 → 最近会话;会话ID(源 id 或 import-<src>)
1804
+ * 精确匹配;关键词匹配标题或源 id(多个命中列候选,绝不猜测)。
1805
+ * @param index - runScan 输出的索引(已标注 import 状态)。
1806
+ * @param ref - 命令输入。
1807
+ * @returns `{ kind: 'one', session }` | `{ kind: 'many', candidates }` | `{ kind: 'none' }`。
1808
+ */
1809
+ export function resolveResumeTarget(index, ref) {
1810
+ const sessions = (index.projects ?? [])
1811
+ .flatMap((p) => p.sessions ?? [])
1812
+ .filter((s) => !s.error)
1813
+ .sort((a, b) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0))
1814
+ const trimmed = (ref ?? '').trim()
1815
+ if (trimmed.length === 0 || trimmed === 'latest') {
1816
+ return sessions[0] ? { kind: 'one', session: sessions[0] } : { kind: 'none' }
1817
+ }
1818
+ const exact = sessions.find((s) => s.sessionId === trimmed || s.import?.dshSessionId === trimmed)
1819
+ if (exact) return { kind: 'one', session: exact }
1820
+ const keyword = trimmed.toLowerCase()
1821
+ const matches = sessions.filter((s) => (
1822
+ (s.title ?? '').toLowerCase().includes(keyword) || (s.sessionId ?? '').toLowerCase().includes(keyword)
1823
+ ))
1824
+ if (matches.length === 1) return { kind: 'one', session: matches[0] }
1825
+ if (matches.length > 1) {
1826
+ return {
1827
+ kind: 'many',
1828
+ candidates: matches.slice(0, 10).map((s) => `${s.sessionId} — ${s.title ?? '(无标题)'}`),
1829
+ }
1830
+ }
1831
+ return { kind: 'none' }
1832
+ }
1833
+
1834
+ /**
1835
+ * /resume-claude 定位快路径(A6):精确 sessionId / import-<src> id 无需全量
1836
+ * 扫描——直接用 imports.json 映射 + index.json 书签定位;未命中返回 null
1837
+ * (调用方回退 runScan 增量扫描)。latest/关键词不走快路径(依赖最近活动
1838
+ * 排序与标题匹配,必须扫描索引)。
1839
+ * @param ctx - Cordis 上下文(当前仅用于签名对称)。
1840
+ * @param ref - 命令输入。
1841
+ * @returns `{ session }` 或 null。
1842
+ */
1843
+ export async function resolveResumeFast(ctx, ref) {
1844
+ const trimmed = (ref ?? '').trim()
1845
+ if (trimmed.length === 0 || trimmed === 'latest') return null
1846
+ const cacheDir = resolveCacheDir()
1847
+ const [imports, cache] = await Promise.all([loadImports(cacheDir), loadCache(cacheDir)])
1848
+ const files = cache?.files ?? {}
1849
+ for (const [sourcePath, entry] of Object.entries(imports)) {
1850
+ const record = unwrapImport(entry)
1851
+ if (record?.dshId === trimmed) {
1852
+ const header = files[sourcePath]
1853
+ return header && !header.error ? { session: header } : null
1854
+ }
1855
+ }
1856
+ const bySessionId = Object.values(files).find((s) => s && !s.error && s.sessionId === trimmed)
1857
+ return bySessionId ? { session: bySessionId } : null
1858
+ }
1859
+
1860
+ /**
1861
+ * 注册 claude-import-all 与 resume-claude 命令(F15/F17)。
1862
+ * 命令由用户直接触发,不经模型回合;结果直接渲染 UI,并注入当前会话上下文。
1863
+ * @param ctx - Cordis 上下文。
1864
+ * @param config - 插件配置。
1865
+ */
1866
+ export function registerCommands(ctx, config) {
1867
+ withService(ctx, 'commands', (commands) => {
1868
+ if (typeof commands.register !== 'function') return
1869
+ registerCommandDefinitions(ctx, config, commands)
1870
+ })
1871
+ }
1872
+
1873
+ function registerCommandDefinitions(ctx, config, commands) {
1874
+ const maxBytes = config.maxTranscriptBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES
1875
+ const resumeMaxChars = config.resumeMaxChars ?? DEFAULT_HANDOFF_MAX_CHARS
1876
+ const claudeHome = () => config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome()
1877
+
1878
+ // F15:一条命令完成 扫描 → 导入 → 注入上下文 → 输出报告。
1879
+ commands.register({
1880
+ name: 'claude-import-all',
1881
+ description: 'One-shot migration: scan the local Claude Code data and import every session, then report and inject the summary into the current session(一键全量迁移:扫描本机 Claude Code 数据并导入全部会话,输出报告并注入当前会话)',
1882
+ handler: async (invocation) => {
1883
+ try {
1884
+ const fs = requireFs(ctx)
1885
+ const target = await fs.resolve(path.join(claudeHome(), 'projects'))
1886
+ const info = await fs.stat(target)
1887
+ if (!info || info.type !== 'directory') {
1888
+ return { kind: 'error', text: '未找到 Claude projects 目录(' + claudeHome() + '/projects)。' }
1889
+ }
1890
+ const batch = await importDirectory(
1891
+ ctx, target, { recursive: true }, maxBytes, undefined,
1892
+ config.importConcurrency ?? DEFAULT_IMPORT_CONCURRENCY,
1893
+ invocation.signal, config,
1894
+ )
1895
+ const lines = renderImport({}, { mode: 'batch', ...batch }).map((b) => b.text)
1896
+ const summaryText = 'Claude 全量迁移完成。\n\n' + lines.join('\n')
1897
+ + '\n\n已导入会话即时落盘,无需重启 dsh:服务端会话/工作区列表立即可见。'
1898
+ + '已打开的 Web 页面请刷新一次会话列表(浏览器刷新或面板「刷新会话列表」按钮)后在会话列表中点开续聊。'
1899
+ const injected = injectContext(invocation.agent, summaryText)
1900
+ return {
1901
+ kind: 'success',
1902
+ text: summaryText + (injected ? '\n\n(报告已注入当前会话上下文。)' : ''),
1903
+ }
1904
+ } catch (err) {
1905
+ return { kind: 'error', text: 'claude-import-all 失败:' + String((err && err.message) || err) }
1906
+ }
1907
+ },
1908
+ })
1909
+
1910
+ // F17:未导入先导入,再以交接摘要方式在当前会话继续。
1911
+ commands.register({
1912
+ name: 'resume-claude',
1913
+ description: 'Continue a Claude Code session: latest | session id | title keyword; imports first if needed, then continues via a static handoff summary(继续 Claude Code 会话:latest | 会话ID | 标题关键词;未导入的先导入,再以静态交接摘要继续)',
1914
+ input: { hint: 'latest | 会话ID | 标题关键词' },
1915
+ handler: async (invocation) => {
1916
+ try {
1917
+ const ref = invocation.rawInput.trim()
1918
+ // 快路径:精确 id 直接由 imports.json + 缓存书签定位,省掉全量/增量扫描。
1919
+ const fast = await resolveResumeFast(ctx, ref)
1920
+ const resolved = fast ?? resolveResumeTarget(await runScan(ctx, config, {}), ref)
1921
+ if (resolved.kind === 'none') {
1922
+ return { kind: 'error', text: '未找到匹配的 Claude 会话。可用 /claude-import-all 先全量迁移,或运行 claude_scan 后重试。' }
1923
+ }
1924
+ if (resolved.kind === 'many') {
1925
+ return {
1926
+ kind: 'success',
1927
+ text: '关键词匹配到多个会话,请选择其一:\n- ' + resolved.candidates.join('\n- '),
1928
+ }
1929
+ }
1930
+ const session = resolved.session
1931
+ let dshId = session.import?.dshSessionId
1932
+ const fs = requireFs(ctx)
1933
+ const target = await fs.resolve(session.file)
1934
+ const info = await fs.stat(target)
1935
+ if (info && typeof info.size === 'number' && info.size > maxBytes) {
1936
+ return {
1937
+ kind: 'error',
1938
+ text: `transcript 过大(${info.size} 字节 > maxTranscriptBytes ${maxBytes}):` +
1939
+ '请调高 maxTranscriptBytes,或改由 /claude-import-all 全量迁移。',
1940
+ }
1941
+ }
1942
+ invocation.signal?.throwIfAborted()
1943
+ // 只读一次原文:同一转换结果既用于幂等落盘,也用于生成交接摘要(A6 消除双读)。
1944
+ const raw = await fs.readText(target)
1945
+ const converted = convertClaudeJsonl(raw, {})
1946
+ if (!dshId) {
1947
+ const persisted = await listPersistedIds(ctx)
1948
+ const single = await persistConverted(ctx, converted, {}, persisted, session.file, {
1949
+ sizeBytes: info && typeof info.size === 'number' ? info.size : undefined,
1950
+ mtimeMs: info && typeof info.mtimeMs === 'number' ? info.mtimeMs : undefined,
1951
+ }, config)
1952
+ dshId = single.sessionId
1953
+ }
1954
+ // resumeMode='agents'(D2):尝试经 ctx.agents.resume 真正打开导入会话;
1955
+ // 服务缺失/失败回退到交接摘要注入(导入会话本身已含完整历史)。
1956
+ if (config.resumeMode === 'agents') {
1957
+ const agents = ctx.get('agents')
1958
+ if (agents && typeof agents.resume === 'function') {
1959
+ try {
1960
+ await agents.resume({ resumeSessionId: dshId })
1961
+ return {
1962
+ kind: 'success',
1963
+ text: `已恢复 DSH 会话 ${dshId}(含完整导入历史),可在会话列表中继续。`,
1964
+ }
1965
+ } catch {
1966
+ // agents 不可用/恢复失败:回退注入路径。
1967
+ }
1968
+ }
1969
+ }
1970
+ const handoff = buildHandoff(converted, {
1971
+ maxChars: resumeMaxChars,
1972
+ title: session.title,
1973
+ ...(typeof session.cwd === 'string' ? { sourceCwd: session.cwd } : {}),
1974
+ ...(workspaceModeOf(config) === 'claudecode' ? { workspaceCwd: resolveClaudecodeDir(config) } : {}),
1975
+ })
1976
+ const injected = injectContext(invocation.agent, handoff)
1977
+ return {
1978
+ kind: 'success',
1979
+ text: `${handoff}\n\nDSH 会话:${dshId}(可在会话列表中打开继续)`
1980
+ + (injected ? '\n\n(交接摘要已注入当前会话,下一条消息即可继续。)' : ''),
1981
+ }
1982
+ } catch (err) {
1983
+ return { kind: 'error', text: 'resume-claude 失败:' + String((err && err.message) || err) }
1984
+ }
1985
+ },
1986
+ })
1987
+
1988
+ // D5:重置本插件缓存(扫描书签 + 导入映射),保留已导入的 DSH 会话。
1989
+ commands.register({
1990
+ name: 'claude-move-reset',
1991
+ description: 'Reset this plugin cache (scan bookmarks and import map); imported DSH sessions are kept(重置本插件缓存:扫描书签与导入映射,保留已导入的 DSH 会话)',
1992
+ handler: async () => {
1993
+ try {
1994
+ await resetCacheFiles(resolveCacheDir())
1995
+ return {
1996
+ kind: 'success',
1997
+ text: '已重置 claude-move 缓存(扫描书签与导入映射)。下次扫描将全量重建;已导入的 DSH 会话不受影响。',
1998
+ }
1999
+ } catch (err) {
2000
+ return { kind: 'error', text: 'claude-move-reset 失败:' + String((err && err.message) || err) }
2001
+ }
2002
+ },
2003
+ })
2004
+ }
2005
+
2006
+ // ── 面板 JSON 路由(F16):ctx.webServer 公开 seam ─────────────────────────────
2007
+
2008
+ /** 发送 JSON 响应(node:http)。 */
2009
+ function sendJson(res, status, value) {
2010
+ const body = JSON.stringify(value)
2011
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
2012
+ res.end(body)
2013
+ }
2014
+
2015
+ /** 读取 JSON 请求体(上限 1 MiB)。 */
2016
+ function readJsonBody(req) {
2017
+ return new Promise((resolve, reject) => {
2018
+ let data = ''
2019
+ req.on('data', (chunk) => {
2020
+ data += chunk
2021
+ if (data.length > 1024 * 1024) {
2022
+ reject(new Error('body too large'))
2023
+ req.destroy()
2024
+ }
2025
+ })
2026
+ req.on('end', () => {
2027
+ try {
2028
+ resolve(data ? JSON.parse(data) : {})
2029
+ } catch (err) {
2030
+ reject(err)
2031
+ }
2032
+ })
2033
+ req.on('error', reject)
2034
+ })
2035
+ }
2036
+
2037
+ /**
2038
+ * 状态变更路由的 CSRF 加固(D6):浏览器请求必须来自 loopback 或同源
2039
+ * (Origin 与 Host 一致);无 Origin 的非浏览器客户端(curl/脚本)放行。
2040
+ * @param req - node:http IncomingMessage。
2041
+ * @returns 是否可信。
2042
+ */
2043
+ export function isTrustedOrigin(req) {
2044
+ const origin = req?.headers?.origin
2045
+ if (typeof origin !== 'string' || origin.length === 0) return true
2046
+ let hostname
2047
+ try {
2048
+ hostname = new URL(origin).hostname
2049
+ } catch {
2050
+ return false
2051
+ }
2052
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1') {
2053
+ return true
2054
+ }
2055
+ const host = req?.headers?.host
2056
+ if (typeof host === 'string' && host.length > 0) {
2057
+ const hostNameOnly = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
2058
+ if (hostNameOnly.toLowerCase() === hostname.toLowerCase()) return true
2059
+ }
2060
+ return false
2061
+ }
2062
+
2063
+ /**
2064
+ * 注册面板路由(enableWebPanel=false 或 headless 无 webServer 时跳过):
2065
+ * - GET /api/claude-move/index → 最近扫描索引(含导入状态与 settings 建议)
2066
+ * - POST /api/claude-move/import → 启动批量/单文件导入任务,返回 jobId
2067
+ * - GET /api/claude-move/progress?job=<id> → 任务进度(面板轮询)
2068
+ * - DELETE /api/claude-move/job?job=<id> → 取消导入任务(B5/D4)
2069
+ * 路由随本插件生命周期自动撤销(webServer.register 返回 disposer)。
2070
+ * @param ctx - Cordis 上下文。
2071
+ * @param config - 插件配置。
2072
+ * @param state - 插件状态。
2073
+ */
2074
+ export function registerWebRoutes(ctx, config, state) {
2075
+ if (config.enableWebPanel === false) return
2076
+ withService(ctx, 'webServer', (webServer) => {
2077
+ if (typeof webServer.register !== 'function') return
2078
+ registerRouteDefinitions(ctx, config, state, webServer)
2079
+ })
2080
+ }
2081
+
2082
+ function registerRouteDefinitions(ctx, config, state, webServer) {
2083
+
2084
+ const maxBytes = config.maxTranscriptBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES
2085
+ const claudeHome = () => config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome()
2086
+ const jobs = new Map()
2087
+ const JOB_RETENTION = 20
2088
+
2089
+ // 官方后台任务服务(B5):特性探测,缺失回退自有 job Map(rc.6 兼容)。
2090
+ const hostJobs = typeof ctx.get === 'function' ? ctx.get('jobs') : undefined
2091
+
2092
+ webServer.register({
2093
+ kind: 'exact',
2094
+ path: '/api/claude-move/index',
2095
+ handler: async (req, res) => {
2096
+ try {
2097
+ const index = await runScan(ctx, config, {})
2098
+ state.invalidateSkills?.()
2099
+ sendJson(res, 200, index)
2100
+ } catch (err) {
2101
+ sendJson(res, 500, { error: String((err && err.message) || err) })
2102
+ }
2103
+ },
2104
+ })
2105
+
2106
+ webServer.register({
2107
+ kind: 'exact',
2108
+ path: '/api/claude-move/import',
2109
+ handler: async (req, res) => {
2110
+ if (req.method !== 'POST') {
2111
+ sendJson(res, 405, { error: 'method not allowed' })
2112
+ return
2113
+ }
2114
+ if (!isTrustedOrigin(req)) {
2115
+ sendJson(res, 403, { error: 'untrusted origin' })
2116
+ return
2117
+ }
2118
+ let body
2119
+ try {
2120
+ body = await readJsonBody(req)
2121
+ } catch (err) {
2122
+ sendJson(res, 400, { error: String((err && err.message) || err) })
2123
+ return
2124
+ }
2125
+ const jobId = randomUUID()
2126
+ const controller = new AbortController()
2127
+ const job = {
2128
+ jobId, status: 'running', total: 0, imported: 0, alreadyImported: 0, skipped: 0, failed: 0, results: [],
2129
+ controller, hostJobId: null,
2130
+ }
2131
+ jobs.set(jobId, job)
2132
+ while (jobs.size > JOB_RETENTION) jobs.delete(jobs.keys().next().value)
2133
+ sendJson(res, 200, { jobId })
2134
+
2135
+ // B5:可选接入官方 ctx.jobs(获得官方 kill/UI 展示),失败回退自有取消面。
2136
+ if (hostJobs && typeof hostJobs.start === 'function') {
2137
+ try {
2138
+ job.hostJobId = hostJobs.start({
2139
+ kind: 'claude-move-import',
2140
+ label: 'claude-move 导入 ' + (body && typeof body.path === 'string' ? body.path : 'all'),
2141
+ run: () => ({
2142
+ cancel() { controller.abort(new Error('claude-move 导入已取消')) },
2143
+ done: new Promise((resolve) => {
2144
+ controller.signal.addEventListener('abort', () => resolve(), { once: true })
2145
+ }),
2146
+ }),
2147
+ })
2148
+ } catch {
2149
+ // 无 serving controller 等:保持自有取消面。
2150
+ job.hostJobId = null
2151
+ }
2152
+ }
2153
+
2154
+ void (async () => {
2155
+ try {
2156
+ const fs = requireFs(ctx)
2157
+ const rawPath = body && typeof body.path === 'string' && body.path !== 'all'
2158
+ ? body.path
2159
+ : path.join(claudeHome(), 'projects')
2160
+ const target = await fs.resolve(rawPath)
2161
+ const info = await fs.stat(target)
2162
+ if (!info) {
2163
+ job.status = 'error'
2164
+ job.error = '路径不存在:' + rawPath
2165
+ return
2166
+ }
2167
+ if (info.type === 'file') {
2168
+ const persisted = await listPersistedIds(ctx)
2169
+ const single = await importTranscript(ctx, target, { force: body && body.force === true }, maxBytes, persisted, undefined, controller.signal, config)
2170
+ Object.assign(job, {
2171
+ status: 'done',
2172
+ total: 1,
2173
+ imported: single.status === 'imported' ? 1 : 0,
2174
+ alreadyImported: single.status === 'already-imported' ? 1 : 0,
2175
+ results: [{ path: rawPath, ...single }],
2176
+ })
2177
+ return
2178
+ }
2179
+ if (info.type !== 'directory') {
2180
+ job.status = 'error'
2181
+ job.error = '不支持的目标类型:' + rawPath
2182
+ return
2183
+ }
2184
+ const done = await importDirectory(ctx, target, {
2185
+ recursive: true, force: body && body.force === true,
2186
+ }, maxBytes, (progress) => Object.assign(job, progress),
2187
+ config.importConcurrency ?? DEFAULT_IMPORT_CONCURRENCY, controller.signal, config)
2188
+ Object.assign(job, done, { status: 'done' })
2189
+ } catch (err) {
2190
+ if (controller.signal.aborted) {
2191
+ job.status = 'cancelled'
2192
+ } else {
2193
+ job.status = 'error'
2194
+ job.error = String((err && err.message) || err)
2195
+ }
2196
+ }
2197
+ })()
2198
+ },
2199
+ })
2200
+
2201
+ webServer.register({
2202
+ kind: 'exact',
2203
+ path: '/api/claude-move/progress',
2204
+ handler: (req, res) => {
2205
+ const url = new URL(req.url ?? '', 'http://localhost')
2206
+ const id = url.searchParams.get('job')
2207
+ const job = id ? jobs.get(id) : undefined
2208
+ if (!job) {
2209
+ sendJson(res, 404, { error: 'unknown job' })
2210
+ return
2211
+ }
2212
+ // 不透出进程内句柄(AbortController/官方 job id)。
2213
+ const { controller: _controller, hostJobId: _hostJobId, ...publicJob } = job
2214
+ sendJson(res, 200, publicJob)
2215
+ },
2216
+ })
2217
+
2218
+ webServer.register({
2219
+ kind: 'exact',
2220
+ path: '/api/claude-move/job',
2221
+ handler: (req, res) => {
2222
+ if (req.method !== 'DELETE') {
2223
+ sendJson(res, 405, { error: 'method not allowed' })
2224
+ return
2225
+ }
2226
+ if (!isTrustedOrigin(req)) {
2227
+ sendJson(res, 403, { error: 'untrusted origin' })
2228
+ return
2229
+ }
2230
+ const url = new URL(req.url ?? '', 'http://localhost')
2231
+ const id = url.searchParams.get('job')
2232
+ const job = id ? jobs.get(id) : undefined
2233
+ if (!job) {
2234
+ sendJson(res, 404, { error: 'unknown job' })
2235
+ return
2236
+ }
2237
+ if (job.hostJobId && hostJobs && typeof hostJobs.kill === 'function') {
2238
+ try {
2239
+ hostJobs.kill(job.hostJobId)
2240
+ } catch {
2241
+ job.controller?.abort(new Error('claude-move 导入已取消'))
2242
+ }
2243
+ } else {
2244
+ job.controller?.abort(new Error('claude-move 导入已取消'))
2245
+ }
2246
+ sendJson(res, 200, { cancelled: true })
2247
+ },
2248
+ })
2249
+
2250
+ webServer.register({
2251
+ kind: 'exact',
2252
+ path: '/api/claude-move/reset',
2253
+ handler: async (req, res) => {
2254
+ if (req.method !== 'POST') {
2255
+ sendJson(res, 405, { error: 'method not allowed' })
2256
+ return
2257
+ }
2258
+ if (!isTrustedOrigin(req)) {
2259
+ sendJson(res, 403, { error: 'untrusted origin' })
2260
+ return
2261
+ }
2262
+ try {
2263
+ await resetCacheFiles(resolveCacheDir())
2264
+ state.invalidateSkills?.()
2265
+ sendJson(res, 200, { reset: true })
2266
+ } catch (err) {
2267
+ sendJson(res, 500, { error: String((err && err.message) || err) })
2268
+ }
2269
+ },
2270
+ })
2271
+ }
2272
+
2273
+ // ── 四合一迁移向导:move_detect / move_preview / move_run + /move ──────────────
2274
+ //
2275
+ // 阶段:detect(四源只读扫描)→ preview(幂等状态/diff/冲突)→ run(审批门 +
2276
+ // 逐项落盘 + manifest 记录)→ report。核心状态机在 lib/wizard.mjs(纯编排),
2277
+ // 本段只做 DSH 端口接线:源解析器/映射器、会话导入(复用 persistConverted +
2278
+ // 各源转换器)、$DSH_HOME/skills 与 AGENTS.md 写入、ctx.commands 注册、
2279
+ // ctx.approval 审批(特性探测,fail-closed)。
2280
+
2281
+ /** 源解析器/映射器注册表(按源标识)。 */
2282
+ const WIZARD_PARSERS = { claude: claudeParser, codex: codexParser, opencode: opencodeParser, hermes: hermesParser }
2283
+ const WIZARD_MAPPERS = { claude: claudeMapper, codex: codexMapper, opencode: opencodeMapper, hermes: hermesMapper }
2284
+
2285
+ /** 向导覆盖的源列表(config.sources 过滤未知值)。 */
2286
+ export function wizardSourcesOf(config) {
2287
+ const want = config?.sources ?? ['claude', 'codex', 'opencode', 'hermes']
2288
+ return (Array.isArray(want) ? want : []).filter((s) => WIZARD_PARSERS[s])
2289
+ }
2290
+
2291
+ /** 向导技能落点:config.skillsDir 或 `$DSH_HOME/skills`(官方用户技能根)。 */
2292
+ export function wizardSkillsDir(config, env = process.env) {
2293
+ if (typeof config?.skillsDir === 'string' && config.skillsDir.trim().length > 0) {
2294
+ return path.resolve(config.skillsDir)
2295
+ }
2296
+ return path.join(env.DSH_HOME || path.join(homedir(), '.dsh'), 'skills')
2297
+ }
2298
+
2299
+ /** 向导记忆/指令落点:config.agentsMdPath 或 `$DSH_HOME/AGENTS.md`。 */
2300
+ export function wizardAgentsMdPath(config, env = process.env) {
2301
+ if (typeof config?.agentsMdPath === 'string' && config.agentsMdPath.trim().length > 0) {
2302
+ return path.resolve(config.agentsMdPath)
2303
+ }
2304
+ return defaultAgentsMdPath(env)
2305
+ }
2306
+
2307
+ /**
2308
+ * 注册一条迁移来的 DSH 命令:把源提示词注入当前会话(绝不执行脚本)。
2309
+ * 命令由 manifest 在 apply 时重建,迁移完成后即时注册。
2310
+ * @param state - 插件状态(registeredCommands 去重)。
2311
+ * @param name - 命令名(kebab-case)。
2312
+ * @param prompt - 提示词全文。
2313
+ * @returns `{ registered: boolean, reason? }`。
2314
+ */
2315
+ export function registerMigratedCommand(state, name, prompt) {
2316
+ if (!state || typeof name !== 'string' || name.length === 0) {
2317
+ return { registered: false, reason: '命令名无效' }
2318
+ }
2319
+ if (state.registeredCommands.has(name)) return { registered: true }
2320
+ // 延迟到 commands 服务可用(apply 阶段重建路径)。
2321
+ if (!state.commandsService || typeof state.commandsService.register !== 'function') {
2322
+ return { registered: false, reason: 'commands 服务不可用' }
2323
+ }
2324
+ const firstLine = String(prompt ?? '').split(/\r?\n/).find((l) => l.trim().length > 0) ?? ''
2325
+ state.commandsService.register({
2326
+ name,
2327
+ description: 'Migrated command (imported by dsh-claude-move from another agent): ' + firstLine.slice(0, 100) + '(迁移导入的命令,注入提示词到当前会话)',
2328
+ handler: async (invocation) => {
2329
+ const injected = injectContext(invocation.agent, String(prompt ?? '').trim())
2330
+ return {
2331
+ kind: 'success',
2332
+ text: (injected ? '(提示词已注入当前会话)' : '') + String(prompt ?? '').trim(),
2333
+ }
2334
+ },
2335
+ })
2336
+ state.registeredCommands.add(name)
2337
+ return { registered: true }
2338
+ }
2339
+
2340
+ /**
2341
+ * 构造向导运行时端口(lib/wizard.mjs 的依赖注入面)。
2342
+ * @param ctx - Cordis 上下文。
2343
+ * @param config - 插件配置。
2344
+ * @param state - 插件状态。
2345
+ * @returns wizard 运行时对象。
2346
+ */
2347
+ export function makeWizardRuntime(ctx, config, state) {
2348
+ const skillsDir = wizardSkillsDir(config)
2349
+ const agentsMdPath = wizardAgentsMdPath(config)
2350
+ const maxBytes = config.maxTranscriptBytes ?? DEFAULT_MAX_TRANSCRIPT_BYTES
2351
+ const homeOf = (source) => {
2352
+ switch (source) {
2353
+ case 'codex': return config.codexHome ? path.resolve(config.codexHome) : codexParser.locateHome()
2354
+ case 'opencode': return config.opencodeDataHome ? path.resolve(config.opencodeDataHome) : opencodeParser.locateHome()
2355
+ case 'hermes': return config.hermesHome ? path.resolve(config.hermesHome) : hermesParser.locateHome()
2356
+ default: return config.claudeHome ? path.resolve(config.claudeHome) : locateClaudeHome()
2357
+ }
2358
+ }
2359
+
2360
+ return {
2361
+ /** 源数据根定位 + 白名单扫描。 */
2362
+ async detect(source, opts = {}) {
2363
+ const parser = WIZARD_PARSERS[source]
2364
+ if (!parser) throw new Error('未知源:' + source)
2365
+ const home = homeOf(source)
2366
+ if (source === 'opencode') {
2367
+ const configHome = config.opencodeConfigHome
2368
+ ? path.resolve(config.opencodeConfigHome)
2369
+ : opencodeParser.locateConfigHome()
2370
+ return parser.detect(home, { configHome, signal: opts.signal })
2371
+ }
2372
+ return parser.detect(home, { signal: opts.signal })
2373
+ },
2374
+ /** 清单 → 迁移计划(各源映射器)。 */
2375
+ async map(source, detection) {
2376
+ const mapper = WIZARD_MAPPERS[source]
2377
+ if (!mapper) throw new Error('未知源:' + source)
2378
+ return mapper.mapSource(source, detection, { skillsDir, agentsMdPath })
2379
+ },
2380
+ /** 目标文件读取(不存在 → null)。 */
2381
+ async readTarget(p) {
2382
+ try {
2383
+ return await readFile(p, 'utf8')
2384
+ } catch {
2385
+ return null
2386
+ }
2387
+ },
2388
+ /** 目标文件写入(只写 $DSH_HOME 下的 skills/AGENTS.md 等落点)。 */
2389
+ async writeTarget(p, content) {
2390
+ await mkdir(path.dirname(p), { recursive: true })
2391
+ await writeFile(p, content, 'utf8')
2392
+ },
2393
+ /** 源文件读取(不存在 → null)。 */
2394
+ async readSource(p) {
2395
+ try {
2396
+ return await readFile(p, 'utf8')
2397
+ } catch {
2398
+ return null
2399
+ }
2400
+ },
2401
+ /** 冲突 rename:技能目录名加 -2/-3 后缀避让。 */
2402
+ async renameTarget(p) {
2403
+ const parent = path.dirname(path.dirname(p))
2404
+ const name = path.basename(path.dirname(p))
2405
+ for (let n = 2; ; n++) {
2406
+ const candidate = path.join(parent, `${name}-${n}`)
2407
+ if (!existsSync(candidate)) return path.join(candidate, 'SKILL.md')
2408
+ }
2409
+ },
2410
+ /** 会话导入:按 provider 转换 → persistConverted(复用一期幂等/增量/force)。 */
2411
+ async importSession(plan, { force }) {
2412
+ const fs = requireFs(ctx)
2413
+ const sourceKey = plan.source.importKey ?? plan.source.file
2414
+ let converted
2415
+ let secrets = { total: 0, hits: [] }
2416
+ let statInfo = {}
2417
+ if (plan.provider === 'opencode') {
2418
+ const loaded = plan.source.storage === 'opencode-legacy'
2419
+ ? await loadLegacySessionRows(plan.source.dataHome, plan.source.sessionId)
2420
+ : loadDbSessionRows(plan.source.file, plan.source.sessionId)
2421
+ if (!loaded) throw new Error('OpenCode 会话不可读:' + plan.source.sessionId)
2422
+ converted = convertOpencodeRows(loaded, {})
2423
+ } else {
2424
+ const target = await fs.resolve(plan.source.file)
2425
+ const info = await fs.stat(target)
2426
+ if (info && typeof info.size === 'number' && info.size > maxBytes) {
2427
+ throw new Error(`transcript 过大(${info.size} 字节 > maxTranscriptBytes ${maxBytes}):请调高 maxTranscriptBytes 后重试`)
2428
+ }
2429
+ const raw = await fs.readText(target)
2430
+ converted = plan.provider === 'codex'
2431
+ ? convertCodexJsonl(raw, {})
2432
+ : convertClaudeJsonl(raw, {})
2433
+ if (!converted.events.some((e) => e.type === 'session/title')) {
2434
+ appendTitleEvent(converted, plan.title)
2435
+ }
2436
+ secrets = scanSecrets(raw)
2437
+ statInfo = {
2438
+ sizeBytes: info && typeof info.size === 'number' ? info.size : undefined,
2439
+ mtimeMs: info && typeof info.mtimeMs === 'number' ? info.mtimeMs : undefined,
2440
+ }
2441
+ }
2442
+ const persisted = await listPersistedIds(ctx)
2443
+ // 向导归组:workspaceMode 'wizard' → imports/<source> 工作区(applyWorkspaceCwd/attachImportedSession 已支持)。
2444
+ const wizardConfig = {
2445
+ ...config,
2446
+ workspaceMode: 'wizard',
2447
+ wizardSource: plan.from ?? plan.provider,
2448
+ moveWorkspaceMode: config.moveWorkspaceMode,
2449
+ }
2450
+ const single = await persistConverted(ctx, converted, { force }, persisted, sourceKey, statInfo, wizardConfig)
2451
+ single.secrets = secrets
2452
+ return single
2453
+ },
2454
+ /** DSH 命令注册(迁移来的纯提示词命令)。 */
2455
+ async registerCommand(name, prompt) {
2456
+ return registerMigratedCommand(state, name, prompt)
2457
+ },
2458
+ /** 命令是否已由本插件注册(幂等预览用)。 */
2459
+ hasCommand(name) {
2460
+ return state.registeredCommands.has(name)
2461
+ },
2462
+ /**
2463
+ * 审批端口(fail-closed):ctx.approval 特性探测;无服务/无 agent/无开放
2464
+ * 回合一律 'unavailable'(不写任何内容)。
2465
+ */
2466
+ async approval(args = {}) {
2467
+ const service = ctx.get('approval')
2468
+ if (!service || typeof service.request !== 'function' || !args.agent) return 'unavailable'
2469
+ try {
2470
+ return await service.request({
2471
+ agent: args.agent,
2472
+ toolName: args.toolName ?? 'move_run',
2473
+ ...(args.callId ? { callId: args.callId } : {}),
2474
+ reason: args.reason,
2475
+ ...(args.signal ? { signal: args.signal } : {}),
2476
+ })
2477
+ } catch {
2478
+ // 无开放回合等:审批不可用,fail-closed。
2479
+ return 'unavailable'
2480
+ }
2481
+ },
2482
+ /** move.json 清单。 */
2483
+ async loadManifest() {
2484
+ return manifestStore.load()
2485
+ },
2486
+ /** 记录一条已执行计划(串行 + 原子写)。 */
2487
+ async record(key, rec) {
2488
+ return manifestStore.update((manifest) => {
2489
+ manifest[key] = { appliedAt: new Date().toISOString(), ...rec }
2490
+ })
2491
+ },
2492
+ /**
2493
+ * 会话导入状态(幂等预览):imports.json + 持久化列表;源轮次增加 →
2494
+ * 'updates'(增量续写)。
2495
+ */
2496
+ async sessionStatus(source) {
2497
+ const key = source.importKey ?? source.file
2498
+ const imports = await loadImports(resolveCacheDir())
2499
+ const rec = unwrapImport(imports[key])
2500
+ if (!rec || typeof rec.dshId !== 'string') return 'none'
2501
+ const persisted = await listPersistedIds(ctx)
2502
+ if (!persisted.has(rec.dshId)) return 'none'
2503
+ if (typeof rec.turns === 'number' && typeof source.turns === 'number' && source.turns > rec.turns) {
2504
+ return 'updates'
2505
+ }
2506
+ return 'imported'
2507
+ },
2508
+ }
2509
+ }
2510
+
2511
+ /** move_detect 输出 schema(宽松,源内部结构由解析器决定)。 */
2512
+ const moveDetectSchema = {
2513
+ type: 'object',
2514
+ additionalProperties: true,
2515
+ properties: {
2516
+ stats: { type: 'object', additionalProperties: true, required: true },
2517
+ sources: { type: 'array', required: true },
2518
+ },
2519
+ }
2520
+
2521
+ /** move_preview 输出 schema。 */
2522
+ const movePreviewSchema = {
2523
+ type: 'object',
2524
+ additionalProperties: true,
2525
+ properties: {
2526
+ counts: { type: 'object', additionalProperties: true, required: true },
2527
+ conflicts: { type: 'array', required: true },
2528
+ previews: { type: 'array', required: true },
2529
+ },
2530
+ }
2531
+
2532
+ /** move_run 输出 schema(宽松:execution 报告字段)。 */
2533
+ const moveRunSchema = {
2534
+ type: 'object',
2535
+ additionalProperties: true,
2536
+ properties: {
2537
+ approved: { type: 'boolean' },
2538
+ outcome: { type: 'string' },
2539
+ applied: { type: 'integer' },
2540
+ skipped: { type: 'integer' },
2541
+ conflictSkipped: { type: 'integer' },
2542
+ unsupported: { type: 'integer' },
2543
+ failed: { type: 'integer' },
2544
+ results: { type: 'array' },
2545
+ },
2546
+ }
2547
+
2548
+ /** 源显示名(报告用)。 */
2549
+ const SOURCE_LABELS = { claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', hermes: 'Hermes' }
2550
+
2551
+ /** move_detect 结果摘要(中文,一句角色陈述开头)。 */
2552
+ export function renderMoveDetect(args, value) {
2553
+ const lines = [personaSentence('迁移', 'zh') + '检测到以下可迁移内容:']
2554
+ for (const d of value.sources ?? []) {
2555
+ const label = SOURCE_LABELS[d.source] ?? d.source
2556
+ lines.push(`- ${label}(${d.home}${d.homeExists ? '' : ',不存在'}):` +
2557
+ `会话 ${d.sessions?.length ?? 0}、技能 ${d.skills?.length ?? 0}、记忆 ${d.memories?.length ?? 0}、` +
2558
+ `指令 ${d.instructions?.length ?? 0}、命令 ${d.commands?.length ?? 0}、钩子 ${d.hooks?.length ?? 0}` +
2559
+ (d.errors?.length ? `、错误 ${d.errors.length}` : ''))
2560
+ }
2561
+ if (!(value.sources ?? []).length) lines.push('- 未选择任何源(检查 config.sources)。')
2562
+ lines.push('下一步:move_preview 查看逐项状态与冲突 diff;move_run 执行迁移。')
2563
+ return [{ type: 'text', text: lines.join('\n') }]
2564
+ }
2565
+
2566
+ /** move_preview 结果摘要(状态计数 + 冲突 diff)。 */
2567
+ export function renderMovePreview(args, value) {
2568
+ const c = value.counts ?? {}
2569
+ const lines = [personaSentence('迁移', 'zh') + `预览完成:新增 ${c.new ?? 0}、更新 ${c.changed ?? 0}、` +
2570
+ `幂等跳过 ${c.unchanged ?? 0}、冲突 ${c.conflict ?? 0}、不支持 ${c.unsupported ?? 0}。`]
2571
+ for (const conflict of value.conflicts ?? []) {
2572
+ lines.push(`- 冲突:${conflict.key}(${conflict.reason ?? ''})`)
2573
+ if (conflict.existing) {
2574
+ lines.push(` 目标现状:${String(conflict.existing).slice(0, 200)}`)
2575
+ }
2576
+ for (const line of (conflict.diff ?? []).slice(0, 12)) lines.push(' ' + line)
2577
+ if ((conflict.diff ?? []).length > 12) lines.push(' …(diff 截断)')
2578
+ }
2579
+ for (const p of value.previews ?? []) {
2580
+ if (p.status === 'unsupported') lines.push(`- 不支持:${p.key}(${p.reason ?? ''})`)
2581
+ }
2582
+ lines.push('执行:move_run { resolve: { "<key>": "skip|overwrite|rename|merge" } } 逐项选择冲突解法(默认跳过,绝不猜测)。')
2583
+ return [{ type: 'text', text: lines.join('\n') }]
2584
+ }
2585
+
2586
+ /** move_run 结果摘要(报告:短 persona 开头 + 计数 + 不支持清单)。 */
2587
+ export function renderMoveRun(args, value) {
2588
+ const lines = reportLines(value)
2589
+ return [{ type: 'text', text: lines.join('\n') }]
2590
+ }
2591
+
2592
+ /** 构造 move_detect / move_preview / move_run 三个工具。 */
2593
+ function makeMoveTools(ctx, config, state) {
2594
+ const runtime = makeWizardRuntime(ctx, config, state)
2595
+ const sourcesOf = (raw) => {
2596
+ if (typeof raw === 'string' && raw !== 'all' && WIZARD_PARSERS[raw]) return [raw]
2597
+ return wizardSourcesOf(config)
2598
+ }
2599
+
2600
+ const detectTool = defineTool({
2601
+ name: 'move_detect',
2602
+ description:
2603
+ 'Detect migratable data from other coding agents (Claude Code / Codex / OpenCode / Hermes) with a read-only whitelist scan: sessions, skills, memories, instruction files, commands and hooks. Returns per-source counts and errors. Use move_preview next for per-item status and conflict diffs. ' +
2604
+ '(只读白名单扫描四源(Claude Code/Codex/OpenCode/Hermes)可迁移内容:会话/技能/记忆/指令/命令/钩子,逐源计数与错误。下一步 move_preview。)',
2605
+ parameters: {
2606
+ source: {
2607
+ type: 'string',
2608
+ enum: ['all', 'claude', 'codex', 'opencode', 'hermes'],
2609
+ description: "可选:'all'(默认)或单个源标识。",
2610
+ },
2611
+ },
2612
+ output: { schema: moveDetectSchema, render: renderMoveDetect },
2613
+ async execute(args, exec) {
2614
+ const detections = []
2615
+ for (const source of sourcesOf(args?.source)) {
2616
+ exec?.signal?.throwIfAborted()
2617
+ detections.push(await runtime.detect(source, { signal: exec?.signal }))
2618
+ }
2619
+ return mergeDetections(detections)
2620
+ },
2621
+ })
2622
+
2623
+ const previewTool = defineTool({
2624
+ name: 'move_preview',
2625
+ description:
2626
+ 'Preview the four-source migration plan: per-item idempotent status (new/changed/unchanged/conflict/unsupported), conflict diffs when a target was manually edited, and counts. No writes. Use move_run to execute; pass resolve per conflict key (skip/overwrite/rename/merge, default skip). ' +
2627
+ '(预览四源迁移计划:逐项幂等状态(新增/更新/跳过/冲突/不支持)、目标被手工修改时的冲突 diff 与计数。零写入。执行用 move_run,冲突按 key 传 resolve。)',
2628
+ parameters: {
2629
+ source: {
2630
+ type: 'string',
2631
+ enum: ['all', 'claude', 'codex', 'opencode', 'hermes'],
2632
+ description: "可选:'all'(默认)或单个源标识。",
2633
+ },
2634
+ force: {
2635
+ type: 'boolean',
2636
+ description: '可选:true 时把已迁移项标为重新应用(默认 false)。',
2637
+ },
2638
+ },
2639
+ output: { schema: movePreviewSchema, render: renderMovePreview },
2640
+ async execute(args, exec) {
2641
+ const detections = []
2642
+ for (const source of sourcesOf(args?.source)) {
2643
+ exec?.signal?.throwIfAborted()
2644
+ detections.push(await runtime.detect(source, { signal: exec?.signal }))
2645
+ }
2646
+ const plans = []
2647
+ for (const detection of detections) {
2648
+ const mapped = await runtime.map(detection.source, detection)
2649
+ plans.push(...(mapped.plans ?? []))
2650
+ }
2651
+ const manifest = await runtime.loadManifest()
2652
+ const preview = await runPreview(runtime, plans, manifest, args?.force === true)
2653
+ state.lastWizardRun = { detections, plans, preview, execution: null }
2654
+ return {
2655
+ counts: preview.counts,
2656
+ conflicts: preview.conflicts.map((c) => ({
2657
+ key: c.plan.key,
2658
+ reason: c.reason,
2659
+ ...(c.diff ? { diff: c.diff } : {}),
2660
+ ...(c.existing ? { existing: c.existing } : {}),
2661
+ })),
2662
+ previews: preview.previews.map((p) => ({
2663
+ key: p.plan.key,
2664
+ source: p.plan.from,
2665
+ kind: p.plan.kind,
2666
+ status: p.status,
2667
+ reason: p.reason,
2668
+ })),
2669
+ }
2670
+ },
2671
+ })
2672
+
2673
+ const runTool = defineTool({
2674
+ name: 'move_run',
2675
+ description:
2676
+ 'Execute the four-source migration after approval (ctx.approval; fails closed when unavailable or rejected — nothing is written). Import sessions (resumable), copy/convert skills into $DSH_HOME/skills, append memories/instructions as managed sections of $DSH_HOME/AGENTS.md, register prompt-only commands. Idempotent via move.json manifest; force re-applies; conflicts need per-key resolve (skip/overwrite/rename/merge, default skip). ' +
2677
+ '(审批后执行四源迁移(fail-closed:审批不可用/拒绝时零写入)。导入可续聊会话、技能拷入 $DSH_HOME/skills、记忆/指令追加为 $DSH_HOME/AGENTS.md 管理段、纯提示词命令注册为 DSH 命令。move.json 幂等;force 重应用;冲突按 key 传 resolve。)',
2678
+ parameters: {
2679
+ source: {
2680
+ type: 'string',
2681
+ enum: ['all', 'claude', 'codex', 'opencode', 'hermes'],
2682
+ description: "可选:'all'(默认)或单个源标识。",
2683
+ },
2684
+ selection: {
2685
+ type: 'array',
2686
+ items: { type: 'string' },
2687
+ description: '可选:只执行这些计划 key(move_preview 输出里的 key)。',
2688
+ },
2689
+ resolve: {
2690
+ type: 'object',
2691
+ additionalProperties: true,
2692
+ description: '可选:冲突解法映射 { "<key>": "skip|overwrite|rename|merge" };默认 skip(绝不猜测)。merge 只对 AGENTS.md 段有效。',
2693
+ },
2694
+ force: {
2695
+ type: 'boolean',
2696
+ description: '可选:true 重新应用已迁移项(默认 false)。',
2697
+ },
2698
+ },
2699
+ output: { schema: moveRunSchema, render: renderMoveRun },
2700
+ async execute(args, exec) {
2701
+ const detections = []
2702
+ for (const source of sourcesOf(args?.source)) {
2703
+ exec?.signal?.throwIfAborted()
2704
+ detections.push(await runtime.detect(source, { signal: exec?.signal }))
2705
+ }
2706
+ const plans = []
2707
+ const mapErrors = []
2708
+ for (const detection of detections) {
2709
+ const mapped = await runtime.map(detection.source, detection)
2710
+ plans.push(...(mapped.plans ?? []))
2711
+ for (const err of mapped.errors ?? []) mapErrors.push(err)
2712
+ }
2713
+ const manifest = await runtime.loadManifest()
2714
+ const preview = await runPreview(runtime, plans, manifest, args?.force === true)
2715
+ const execution = await runExecute(runtime, {
2716
+ plans,
2717
+ manifest,
2718
+ force: args?.force === true,
2719
+ resolve: args?.resolve ?? {},
2720
+ selection: args?.selection ?? [],
2721
+ requireApproval: config.requireApproval !== false,
2722
+ approval: runtime.approval,
2723
+ approvalContext: {
2724
+ agent: exec?.agent,
2725
+ toolName: 'move_run',
2726
+ callId: exec?.callId,
2727
+ signal: exec?.signal,
2728
+ },
2729
+ signal: exec?.signal,
2730
+ })
2731
+ state.lastWizardRun = { detections, plans, preview, execution }
2732
+ state.invalidateSkills?.()
2733
+ return { ...execution, ...(mapErrors.length > 0 ? { mapErrors } : {}) }
2734
+ },
2735
+ })
2736
+
2737
+ return { detect: detectTool, preview: previewTool, run: runTool }
2738
+ }
2739
+
2740
+ /**
2741
+ * 注册 /move 命令(detect | preview | run | report [source])。
2742
+ * run 需要审批(审批服务要求模型回合内):非交互/无审批 seam 时注入指示让
2743
+ * 模型调用 move_run 工具完成审批内执行;requireApproval=false 时直接执行。
2744
+ * @param ctx - Cordis 上下文。
2745
+ * @param config - 插件配置。
2746
+ * @param state - 插件状态。
2747
+ */
2748
+ export function registerMoveCommand(ctx, config, state) {
2749
+ withService(ctx, 'commands', (commands) => {
2750
+ if (typeof commands.register !== 'function') return
2751
+ state.commandsService = commands
2752
+ const runtime = makeWizardRuntime(ctx, config, state)
2753
+
2754
+ commands.register({
2755
+ name: 'move',
2756
+ description: 'Four-source migration wizard (Claude Code / Codex / OpenCode / Hermes): detect → preview → run → report(四合一迁移向导:detect 检测 → preview 预览 → run 执行 → report 报告)',
2757
+ input: { hint: 'detect | preview | run | report [source=claude|codex|opencode|hermes|all]' },
2758
+ handler: async (invocation) => {
2759
+ try {
2760
+ const parts = invocation.rawInput.trim().split(/\s+/)
2761
+ const sub = (parts[0] ?? '').toLowerCase() || 'report'
2762
+ const rawSource = parts[1] ?? 'all'
2763
+ const sources = rawSource !== 'all' && WIZARD_PARSERS[rawSource] ? [rawSource] : wizardSourcesOf(config)
2764
+
2765
+ const detections = []
2766
+ for (const source of sources) detections.push(await runtime.detect(source))
2767
+ const plans = []
2768
+ for (const detection of detections) {
2769
+ const mapped = await runtime.map(detection.source, detection)
2770
+ plans.push(...(mapped.plans ?? []))
2771
+ }
2772
+ const manifest = await runtime.loadManifest()
2773
+
2774
+ if (sub === 'detect') {
2775
+ const merged = mergeDetections(detections)
2776
+ const lines = renderMoveDetect({}, merged).map((b) => b.text)
2777
+ return { kind: 'success', text: lines.join('\n') }
2778
+ }
2779
+
2780
+ if (sub === 'preview') {
2781
+ const preview = await runPreview(runtime, plans, manifest, false)
2782
+ state.lastWizardRun = { detections, plans, preview, execution: null }
2783
+ const lines = renderMovePreview({}, {
2784
+ counts: preview.counts,
2785
+ conflicts: preview.conflicts.map((c) => ({ key: c.plan.key, reason: c.reason, diff: c.diff, existing: c.existing })),
2786
+ previews: preview.previews.map((p) => ({ key: p.plan.key, source: p.plan.from, kind: p.plan.kind, status: p.status, reason: p.reason })),
2787
+ }).map((b) => b.text)
2788
+ return { kind: 'success', text: lines.join('\n') }
2789
+ }
2790
+
2791
+ if (sub === 'run') {
2792
+ const execution = await runExecute(runtime, {
2793
+ plans,
2794
+ manifest,
2795
+ force: false,
2796
+ resolve: {},
2797
+ selection: [],
2798
+ requireApproval: config.requireApproval !== false,
2799
+ approval: runtime.approval,
2800
+ approvalContext: {}, // 命令不在模型回合内:approval 必然 unavailable(fail-closed)。
2801
+ })
2802
+ state.lastWizardRun = { detections, plans, preview: null, execution }
2803
+ if (execution.approved === false && config.requireApproval !== false) {
2804
+ // 审批必须在模型回合内:注入指示,由模型调 move_run 完成审批。
2805
+ const guidance = personaParagraph('迁移', 'zh')
2806
+ + '\n\n请在会话中运行 move_run 工具完成迁移(迁移写入需经审批,审批只能在模型回合内发起;'
2807
+ + '预览与冲突信息可用 move_preview 查看)。'
2808
+ + `\n计划数:${plans.length};冲突默认跳过,可用 resolve 逐项选择解法。`
2809
+ injectContext(invocation.agent, guidance)
2810
+ return {
2811
+ kind: 'success',
2812
+ text: guidance + '\n\n(迁移未执行:零写入。)',
2813
+ }
2814
+ }
2815
+ const lines = reportLines(execution).join('\n')
2816
+ return { kind: 'success', text: personaParagraph('迁移', 'zh') + '\n\n' + lines }
2817
+ }
2818
+
2819
+ // report:最近一次执行/预览摘要。
2820
+ const last = state.lastWizardRun
2821
+ if (last?.execution) {
2822
+ const lines = reportLines(last.execution).join('\n')
2823
+ return { kind: 'success', text: personaParagraph('迁移', 'zh') + '\n\n' + lines }
2824
+ }
2825
+ if (last?.preview) {
2826
+ const lines = renderMovePreview({}, {
2827
+ counts: last.preview.counts,
2828
+ conflicts: last.preview.conflicts.map((c) => ({ key: c.plan.key, reason: c.reason, diff: c.diff, existing: c.existing })),
2829
+ previews: [],
2830
+ }).map((b) => b.text)
2831
+ return { kind: 'success', text: lines.join('\n') }
2832
+ }
2833
+ const applied = Object.keys(manifest).length
2834
+ return {
2835
+ kind: 'success',
2836
+ text: personaParagraph('迁移', 'zh')
2837
+ + `\n\n暂无最近执行记录。move.json 共记录 ${applied} 条已迁移项。用 /move detect 或 /move preview 开始。`,
2838
+ }
2839
+ } catch (err) {
2840
+ return { kind: 'error', text: '/move 失败:' + String((err && err.message) || err) }
2841
+ }
2842
+ },
2843
+ })
2844
+ })
2845
+ }
2846
+
2847
+ /**
2848
+ * apply 时从 manifest 重建迁移来的命令注册(重启后仍可用)。
2849
+ * @param ctx - Cordis 上下文。
2850
+ * @param state - 插件状态。
2851
+ */
2852
+ export function registerManifestCommands(ctx, state) {
2853
+ withService(ctx, 'commands', (commands) => {
2854
+ if (typeof commands.register !== 'function') return
2855
+ state.commandsService = commands
2856
+ void (async () => {
2857
+ try {
2858
+ const manifest = await manifestStore.load()
2859
+ for (const record of Object.values(manifest)) {
2860
+ if (record?.action === 'register-command' && typeof record.prompt === 'string') {
2861
+ registerMigratedCommand(state, record.target, record.prompt)
2862
+ }
2863
+ }
2864
+ } catch (err) {
2865
+ console.error('[claude-move] manifest command rebuild failed:', String((err && err.message) || err))
2866
+ }
2867
+ })()
2868
+ })
2869
+ }
2870
+
2871
+ /**
2872
+ * 挂载插件:注册扫描/导入工具、个人上下文贡献、命令与面板路由。
2873
+ * @param ctx - Cordis 上下文。
2874
+ * @param config - 经 Schemastery 校验的插件配置。
2875
+ */
2876
+ export function apply(ctx, config = {}) {
2877
+ const state = makeClaudeState(config)
2878
+ ctx.tools.register(makeScanTool(ctx, config, state))
2879
+ ctx.tools.register(makeImportTool(ctx, config))
2880
+ if (config.enableMove !== false) {
2881
+ const move = makeMoveTools(ctx, config, state)
2882
+ ctx.tools.register(move.detect)
2883
+ ctx.tools.register(move.preview)
2884
+ ctx.tools.register(move.run)
2885
+ }
2886
+ registerContextContributions(ctx, config, state)
2887
+ registerCommands(ctx, config)
2888
+ if (config.enableMove !== false) registerMoveCommand(ctx, config, state)
2889
+ registerWebRoutes(ctx, config, state)
2890
+ registerManifestCommands(ctx, state)
2891
+ }