迁移会话目录、导入导出会话包并跨机同步
30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/15 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add he0119/dsh-session-manager该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:待复验
是什么
dsh 原生插件 · market
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 17 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 132 处,展示前 103 条)
- 读取本地文件src/artifacts.ts:215
return statSync(p).isDirectory() - 写入本地文件src/artifacts.ts:278
mkdirSync(dirname(m.targetPath), { recursive: true }) - 写入本地文件src/artifacts.ts:280
renameSync(m.sourcePath, m.targetPath) - 删除本地文件递归src/artifacts.ts:285
rmSync(m.sourcePath, { recursive: true, force: true }) - 读取本地文件src/attachments.ts:189
if (existsSync(path) && statSync(path).size === bytes.length) return 'present' - 读取本地文件src/attachments.ts:189
if (existsSync(path) && statSync(path).size === bytes.length) return 'present' - 写入本地文件src/attachments.ts:190
mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) - 写入本地文件src/attachments.ts:192
writeFileSync(part, bytes, { mode: 0o400 }) - 写入本地文件src/attachments.ts:193
renameSync(part, path) - 写入本地文件src/attachments.ts:196
chmodSync(path, 0o400) - 读取本地文件src/canonical-path.ts:63
return statSync(real).isDirectory() ? real : undefined - 发起网络请求src/client/api.ts:232
const response = await fetch(`${API_PREFIX}/sync-diff?${query.toString()}`, { - 发起网络请求src/client/api.ts:332
const response = await fetch(`${API_PREFIX}/export`, { - 发起网络请求src/client/api.ts:373
const response = await fetch(url, { - 发起网络请求src/client/api.ts:549
const response = await fetch(`${API_PREFIX}/sync`, { - 发起网络请求src/client/api.ts:579
const response = await fetch(`${API_PREFIX}/sync?mode=apply`, { - 发起网络请求src/client/api.ts:646
await fetch(`${API_PREFIX}/sync?mode=test`, { headers: { accept: 'application/json' } }), - 读取本地文件src/discovery.ts:128
const bytes = statSync(path).size - 读取本地文件src/discovery.ts:138
const buf = take === bytes ? prefix : readFileSync(path) - 读取本地文件src/discovery.ts:209
const names = readdirSync(projectDir) - 读取本地文件src/discovery.ts:215
if (!statSync(dir).isDirectory()) continue - 读取本地文件src/discovery.ts:218
for (const name of readdirSync(dir)) { - 读取本地文件src/discovery.ts:272
projectDirNames = readdirSync(root) - 读取本地文件src/execute.ts:94
const buf = readFileSync(f.path) - 写入本地文件src/execute.ts:101
writeFileSync(tmp, r.buffer) - 读取本地文件src/execute.ts:104
if (decodeAll(readFileSync(tmp)).split('\n')[0] !== expectedHeader) { - 写入本地文件src/execute.ts:107
renameSync(tmp, f.path) - 写入本地文件src/execute.ts:120
mkdirSync(plan.targetProjectDir, { recursive: true }) - 读取本地文件src/execute.ts:121
if (existsSync(s.targetDir)) throw new Error(`target already exists, refusing to overwrite: ${s.targetDir}`) - 写入本地文件src/execute.ts:122
renameSync(s.sourceDir, s.targetDir) - 读取本地文件src/execute.ts:140
if (!existsSync(projectDir)) continue - 读取本地文件src/execute.ts:141
if (readdirSync(projectDir).length > 0) continue - 删除本地文件src/execute.ts:142
rmdirSync(projectDir) - 写入本地文件src/execute.ts:154
writeFileSync(join(backup.dir, 'execution.log'), log.join('\n') + '\n') - 读取本地文件src/execute.ts:182
if (!existsSync(s.targetDir)) { - 读取本地文件src/index.ts:57
const raw = readFileSync(new URL('../package.json', import.meta.url), 'utf8') - 写入本地文件src/journal.ts:109
mkdirSync(join(dir, 'sessions'), { recursive: true }) - 写入本地文件src/journal.ts:119
mkdirSync(dirname(dest), { recursive: true }) - 写入本地文件src/journal.ts:137
mkdirSync(dirname(backupPath), { recursive: true }) - 读取本地文件src/journal.ts:138
if (existsSync(a.sourcePath)) cpSync(a.sourcePath, backupPath, { recursive: true }) - 写入本地文件src/journal.ts:154
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n') - 读取本地文件src/journal.ts:161
return { manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as BackupManifest, manifestPath } - 读取本地文件src/journal.ts:206
if (existsSync(s.targetDir) && s.targetDir !== s.sourceDir) { - 写入本地文件src/journal.ts:208
if (!dryRun) mkdirSync(sourceProjectDir, { recursive: true }) - 读取本地文件src/journal.ts:211
if (existsSync(s.sourceDir)) rmSync(s.sourceDir, { recursive: true, force: true }) - 删除本地文件递归src/journal.ts:211
if (existsSync(s.sourceDir)) rmSync(s.sourceDir, { recursive: true, force: true }) - 写入本地文件src/journal.ts:212
renameSync(s.targetDir, s.sourceDir) - 读取本地文件src/journal.ts:220
if (!existsSync(from)) { - 读取本地文件src/journal.ts:224
if (existsSync(to)) { - 删除本地文件递归src/journal.ts:244
if (existsSync(a.sourcePath)) rmSync(a.sourcePath, { recursive: true, force: true }) - 删除本地文件src/journal.ts:269
if (!dryRun) rmdirSync(projectDir) - 读取本地文件src/migrate.ts:424
if (!existsSync(join(dir, 'manifest.json'))) throw new Error(`这个目录里没有 manifest.json:${dir}`) - 读取本地文件src/migrate.ts:469
entries = readdirSync(deps.backupRoot) - 读取本地文件src/migrate.ts:477
if (!statSync(dir).isDirectory()) continue - 读取本地文件src/plan.ts:151
if (!existsSync(to)) problems.push(`target directory does not exist: ${to}`) - 读取本地文件src/plan.ts:152
else if (!statSync(to).isDirectory()) problems.push(`target is not a directory: ${to}`) - 读取本地文件src/plan.ts:285
if (existsSync(targetDir) && !alreadyAtTarget) problems.push(`target session directory already exists: ${targetDir}`) - 读取本地文件src/plan.ts:346
text: s.files.map((f) => decodeAll(readFileSync(f.path))).join(''), - 读取本地文件src/projection-cache.ts:83
raw = readFileSync(join(cacheDir, `${encodeSegment(query.id)}.json`), 'utf8') - 读取本地文件src/registry.ts:97
return JSON.parse(readFileSync(path, 'utf8')) as WorkspaceRegistryState - 写入本地文件src/registry.ts:107
writeFileSync(tmp, JSON.stringify(reg, null, 2) + '\n') - 写入本地文件src/registry.ts:108
renameSync(tmp, path) - 读取本地文件src/remove.ts:195
if (!existsSync(session.dir)) { - 删除本地文件递归src/remove.ts:273
rmSync(entry.dir, { recursive: true, force: true }) - 读取本地文件src/remove.ts:281
if (!existsSync(projectDir)) continue - 读取本地文件src/remove.ts:283
if (!statSync(projectDir).isDirectory()) continue - 读取本地文件src/remove.ts:284
if (readdirSync(projectDir).length > 0) continue - 删除本地文件src/remove.ts:288
rmdirSync(projectDir) - 读取本地文件src/remove.ts:302
if (existsSync(entry.dir)) problems.push(`session ${entry.id}: 目录还在 ${entry.dir}`) - 读取本地文件src/remove.ts:304
if (backed === undefined || !existsSync(backed.targetDir)) { - 执行外部命令固定命令src/repo.ts:141
const child = spawn('git', ['-C', cwd, ...args], { - 读取本地文件src/scan-cache.ts:83
return readdirSync(dir) - 读取本地文件src/scan-cache.ts:92
return statSync(path).isDirectory() - 读取本地文件src/scan-cache.ts:122
const stat = statSync(path) - 读取本地文件src/scan-cache.ts:149
stat = statSync(join(dir, name)) - 读取本地文件src/sync.ts:275
const bytes = readFileSync(path) - 读取本地文件src/sync.ts:329
const bytes = readFileSync(path) - 读取本地文件src/sync.ts:696
return statSync(path).isDirectory() - 读取本地文件src/sync.ts:1621
const stat = statSync(file.path) - 读取本地文件src/sync.ts:1831
const mine = await readFile(localFile.path) - 读取本地文件src/sync.ts:1940
for (const ref of scanAttachmentRefs(decodeLogText(readFileSync(file.path), file.compression, decodeAll))) { - 删除本地文件递归src/sync.ts:2495
rmSync(session.dir, { recursive: true, force: true }) - 读取本地文件src/tools.ts:149
if (!existsSync(from) || existsSync(to)) return undefined - 写入本地文件src/tools.ts:151
mkdirSync(dirname(to), { recursive: true }) - 写入本地文件src/tools.ts:152
renameSync(from, to) - 读取本地文件src/tools.ts:1334
if (!existsSync(projectDir)) throw new Error(`session project directory does not exist: ${projectDir}`) - 读取本地文件src/tools.ts:1337
for (const dirName of readdirSync(projectDir)) { - 读取本地文件src/tools.ts:1339
if (!statSync(dir).isDirectory()) continue - 读取本地文件src/tools.ts:1340
for (const name of readdirSync(dir)) { - 读取本地文件src/tools.ts:1342
const header = readHeaderQuick(readFileSync(join(dir, name)), decodeAll).header - 读取本地文件src/transfer.ts:212
const buf = readFileSync(file.path) - 读取本地文件src/transfer.ts:253
buf = readFileSync(path) - 读取本地文件src/transfer.ts:480
projectDirNames = readdirSync(root) - 读取本地文件src/transfer.ts:488
if (!statSync(projectDirPath).isDirectory()) continue - 读取本地文件src/transfer.ts:489
entries = readdirSync(projectDirPath) - 读取本地文件src/transfer.ts:497
if (statSync(path).isDirectory()) found.set(name, path) - 写入本地文件src/transfer.ts:878
mkdirSync(entry.dir, { recursive: true }) - 写入本地文件src/transfer.ts:893
writeFileSync(part, next) - 写入本地文件src/transfer.ts:894
renameSync(part, join(entry.dir, file.name)) - 读取本地文件src/web.ts:562
stat = statSync(cwd) - 读取本地文件src/web.ts:1663
const buf = readFileSync(file.path) - 读取本地文件src/web.ts:1786
localLines = decodeAll(readFileSync(file.path)).split('\n') - 读取本地文件src/web.ts:1801
localBytes: statSync(file.path).size,
扫描于 2026/10/31(1 天前) · 范围 全包扫描(包内未声明入口) · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-client-locale@deepseek-ai/dsh-client-ui-primitives@deepseek-ai/dsh-client-ui-slots@deepseek-ai/dsh-tools@deepseek-ai/schemastery给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
把 DSH 的会话库管起来:迁移工作区与会话到新目录,导出 / 导入 .dshsess 会话包,用 WebDAV 在多台机器之间同步。
README
由 DeepSeek 最新模型翻译生成dsh-session-manager
DSH 的会话管理插件:把工作区与会话搬到新目录,把会话导出 / 导入成 .dshsess 包,以及用 WebDAV 在多台机器之间同步会话。
| English
在 DSH 里,「会话属于哪个工作区」不是可改字段,而是由会话日志 header 里的 cwd 推导出来的:宿主既没有 move / reassign API,也不接受项目目录与 cwd 不一致的日志。所以手工搬目录,轻则会话变成 Ungrouped,重则加载时报 corrupt session log。本插件把「改 header cwd + 移动日志目录 + 让宿主当场认下新的归属」三件事一起做,每次写盘前都会先摆出这份计划、事后能逐字节回滚。(为什么必须这样,见 .agents/notes/implemented/。)
同步走的是同一条路:远端只放 .dshsess 包,拉取时按配置里的映射把 cwd 改写成这台机器的路径,所以两台机器的同一个项目处在不同目录也能互通。
展开完整文档(README 剩余 1 节)
dsh-session-manager DSH 的会话管理插件:把工作区与会话搬到新目录,把会话导出 / 导入成 .dshsess 包,以及用 WebDAV 在多台机器之间同步会话。 | English 在 DSH 里,「会话属于哪个工作区」不是可改字段,而是由会话日志 header 里的 cwd 推导出来的:宿主既没有 move / reassign API,也不接受项目目录与 cwd 不一致的日志。所以手工搬目录,轻则会话变成 Ungrouped,重则加载时报 corrupt session log。本插件把「改 header cwd + 移动日志目录 + 让宿主当场认下新的归属」三件事一起做,每次写盘前都会先摆出这份计划、事后能逐字节回滚。(为什么必须这样,见 .agents/notes/implemented/。) 同步走的是同一条路:远端只放 .dshsess 包,拉取时按配置里的映射把 cwd 改写成这台机器的路径,所以两台机器的同一个项目处在不同目录也能互通。 它能做什么 | 入口 | 适合什么 | | 设置里的「会话管理」页 | 日常使用:在「会话」分页里逐条归档、重算名称或删除,或者勾上要搬的会话(可以跨目录,源目录被删掉也照旧)、点动作行上那枚「迁移」在弹窗里选目标目录;在「传输」分页里导出 / 导入;在「同步」分页里按 WebDAV 配置同步;在「备份」分页里回滚或恢复 | | 5 个模型工具 | 直接跟会话说「把这个工作区的会话搬到 ~/dev/xxx」或「跟远端同步一下」,由模型先预演再落盘 | 两个入口的迁移编排是同一份代码,所以弹窗里说会迁移几条,实做就是几条。 页面上的会写盘的动作(删除 / 迁移 / 导入 / 同步 / 回滚)都是一个按钮开一个确认弹窗:弹窗里摆的就是那份只读计划(哪些会话、落到哪、备份落在哪、有什么问题),按「确认」才落盘,按「取消」则什么都不发生。 模型工具那一路仍然是 plan / apply 两次调用——模型自己先看清楚再落盘。 安装 从 npm 装(推荐): 用宿主自己的插件命令:它会替你建/填充 profile,并维护依赖与锁文件 npx @deepseek-ai/dsh@next plugin --profile desktop add @he0119/dsh-session-manager 然后重启 DSH 开发时装本目录(lib/ 不进 git,所以先构建): pnpm install && pnpm run build npx @deepseek-ai/dsh@next plugin --profile desktop add /path/to/dsh-session-manager 然后重启 DSH 装完重启 DSH,设置 的左侧导航里会多出一页「会话管理」(排在官方那几页之后)。 本包不支持 github: 形式的安装:仓库把构建放在 prepublishOnly、lib/ 不入库,git 装法拿不到产物。 宿主版本要 0.2.0-rc.1 及之后的 0.2.x(engines.dsh 与两个 DSH peer 都是 ^0.2.0-rc.1, @deepseek-ai/cordis 另写 ^4.0.4)。宿主按 peer 范围决定装不装:范围收不下运行中的那个版本,整条 bundle 会被跳过(日志里是 skipping profile bundle … is incompatible with dsh …)。它判范围时带 includePrerelease,所以同一 minor 线上的新 rc(0.2.1-rc.1)照旧收得下;换 minor 线(0.3.0-rc.1)时要跟着改这一行。 使用 设置 → 会话管理 每个要等一会儿的动作都会报进度:迁移、删除、回滚、导入(预演与落地)的弹窗正文里,归档与打开页面那次整库扫描的原地,写的是「正在改写日志 8 / 253」+ 一条进度条 + 当前那一条的标题。分母是这一段真正会做的条数(跳过的那些不算进去,否则条永远走不满);没有分母的段落(读远端索引、写工作区注册表)只说在做什么、不画条。落地那一段里弹窗标题从「将要迁移」换成「迁移中…」,取消键禁用——中途撒手会在库里留下半截状态。唯一的例外是导出:它的响应体就是那个包,没有"第几条"可讲。 会话——对着整个会话库逐条管理 - 列表按目录分组,组头可折叠(「全部收起 / 全部展开」在列表上方),组头那一下就是整组勾选——"把这个旧项目的会话都归档 / 都删掉"因此是一次点击。组头只写着这一组的名字:注册表里的工作区名称,没加入工作区的目录就取项目身份(有 git remote 时用它的最后一段,如 dsh-session-manager;没有才退回显示本机路径),所以行里不再重复归属那一列,省下的宽度留给标题;项目身份与本机路径都在名字那一格的悬浮提示里(两行:身份在上、路径在下——同一个仓库在本机的两个克隆靠路径区分);有项目身份的目录还在名字后面挂一枚主机名小标签(github.com、git.hehome.xyz),扫一眼就知道哪些目录是认得出身份的仓库,标签自己的悬浮提示给全整条身份。 落在侧边栏「未分组」那一组里的会话在行上挂「未分组」小标签(组头说的是目录,标签说的是侧边栏把它放在哪儿)。子智能体会话缩进到父会话的下一级(跟着它、排在它后面;父会话在别的目录组里时留在自己组里缩进并标明父在哪儿),"这条属于谁"不必读标签猜。每一行显示标题、字节数与创建时间;宿主侧边栏看不见的会话也在这里(那边点不到它们),行上挂标签说明原因:「子智能体」/「空白」/「已归档」,还活在宿主内存里的那条挂「活动中」; - 筛选:一排小胶囊——子智能体 / 空白 / 已归档 / 未分组 / 活动中,各带库里的条数;勾几枚看几类(多选=任一命中),「全部」一键清空;下面一行是标题 / id 搜索框("按 id 指名道姓"与"按标题回忆上次那个问题"都常用,所以两个都搜)。筛过之后头部报「显示 N / M 条」,「全选」选的是眼下列出来的这些(筛出空白再一键全选就是要删它们),旁边常驻一枚「清空」——它清的是整个勾选集,所以勾了一部分时也清得掉;勾选不会因为切换筛选而丢; - 归档 / 取消归档:勾选后一键收起或放回。走宿主自己的归档能力,即时生效——侧边栏马上跟着变,不必重启;勾父会话会连它的子智能体一起归档(族是一个单位,理由见下面删除那节);宿主没提供这个服务(非 Web profile)时按钮置灰并说明原因; - 重新生成名称:勾选后一键请宿主的名称服务按整段对话重算名称。历史会话常常停在"首句前几个词 / 40 字节"的兜底名称上(含网址的首句会被切成半截链接),而名称 provider 只在"又有新的用户消息"时才修订一次——这一枚按钮就是把它补上。逐条报进度(每条要整份解一次日志再跑一次模型调用),算完之后分四类说清楚:改了 / 算过但没变 / 跳过 / 失败。跳过不是失败,它是"本来就不该动它":你手动改过名的(重算会覆盖你起的名字)、子智能体会话(名称在侧边栏里看不见)、还没有用户消息的、从没有过主模型请求的(没有可用的模型路由)。宿主没有名称服务时按钮置灰并说明原因。"你手动改过名的"那批想一起算:接口(POST …/retitle)里带 includePinned: true 就放行——界面刻意不给这个开关,默认语义就是"别动用户的名字"(一次性清理"名称是脚本或导入写下的"那些会话时用得上); - 看内容:鼠标停在某一行上,行尾就出现一枚「内容」(它顶掉那一行的时刻;触屏上一直摆着),点开就是这条会话的完整内容——日志逐条解析成时间线:人类消息、模型回答、思考(默认折起)、工具调用与结果、系统消息,以及其余一切事件(压缩、权限、审批、重试…连名称是怎么定下来的都在)。按轮次分节,点节标题就把这一轮折起来(不属于任何一轮的会话级事件自己一段,那段没有标题可折,所以不折)。工具结果自己不带工具名,视图按 callId 从调用那条补上;一条事件一行,seq 会跳号所以只当参照物。分页(一次 80 条起,滚到底自己往下接),单格文本超长时如实标出"截断了 N 个字符"并给一个「查看全文」;读不出来的坏行照实说有几条、在第几行,其余事件一条不少。顶上按种类筛选(我 / 模型 / 工具 / 其它)。这一页不经过宿主,所以侧边栏看不见的那些(空会话、孤儿目录、已归档、子智能体、导入的会话)一样能看; - 删除:勾选 → 点「删除所选」,弹窗里列出会被删的每一条、日志数与备份位置 → 确认。删除先备份到本插件的备份根,再删掉整个会话目录;删完侧边栏要等宿主重新扫描才会少掉那几行。还活在宿主内存里的会话会被拒绝——重启 DSH 让宿主放手,之后别先点开它再删(Web 端没有单条关闭的动作,见「说明」)。想反悔就去「备份」分页里点「恢复」; - 子智能体跟着父会话走:勾一条父会话,它的子智能体会话(连同更深的那些)会一起删——弹窗里逐条列出并标明"随父会话删",一个备份装下整族(父日志一没,子会话在侧边栏里就再没有入口,留在盘上等于变成看不见的残留)。子智能体不能单独删 / 单独归档 / 单独导出:它的勾选框是灰的(悬浮提示里写着该勾哪条父会话),直接调接口也会被拒并指名父会话——只删它会在父会话日志里留一条指着不存在会话的条目。父会话已经不在库里的孤儿例外:没有可跟随的会话,它只能单独收拾。分叉出来的会话不算:它是自洽的一条普通会话(源会话的历史已经拷进它自己的日志),父删掉它照样能打开,所以不带走。 迁移——把勾中的会话搬到一个目录 - 迁移就在「会话」分页的动作行上,源是你勾的那些会话,可以跨目录:整库本来就是那一页的列表(同一套按目录分组、行上的标签、类别胶囊与搜索框,勾选也一样),没有单独的迁移分页。所以"项目文件夹被删掉、项目重建到别处之后把会话迁过去"就是:在列表里勾上那几条(它们在侧边栏里落在「未分组」那一组,那只是它们的标签),点动作行上那枚「迁移」,在弹窗里选重建后的目录——一次搬完,不必按目录各来一次; - 目标目录在弹窗里选:那个字段与「传输」页的落地目录是同一个控件、同一份候选(值控件点开候选面板,也能手输路径或去文件系统里挑)。候选面板里一行两行字(名字在上,本机路径在下,整串在悬浮提示里),顶上那个筛选框按路径、工作区名称或项目身份收窄列表——它只筛候选、不改值;点一行就是选中(随即收起)。盘上已经没有的那些(工作区还登记着,目录被删掉或改了名)照旧列在名单里,但挂一枚「不存在」的标签、点不动:选它们只会撞上「目标工作区目录不存在」。有 git remote 的目录,值控件里那一行是项目身份 + 本机路径(github.com/he0119/dsh-session-manager — /home/uy_sun/dev/dsh-session-manager):这枚控件只有一行可用,路径不能从这儿消失——同一个仓库在本机的两个克隆只能靠它区分。目标目录必须已存在;旁边是可选的新建工作区名称与是否连带会话创建过的文件。目标一选好就有一份只读的预演(见下面那条),改目标会重取一份; - 源目录不必还在盘上:清理文件夹时把项目文件夹删掉之后,会话日志还留在库里、header 里的 cwd 也还是那个路径,勾上它们、目标选重建后的目录就迁过去了。从前那种"先选一个源目录"的做法在这里反而没有落点:那批会话可以散在好几个目录里,而一次迁移只有一个目标,只能按目录各来一次; - 勾得动的是"这一页能收拾的",搬得走的是"侧边栏看得见的":清单里唯一勾不动的是挂在父会话下面的子智能体会话(勾选框是灰的,悬浮提示点名该勾哪条父会话)——空白与已归档的那些在这一页照旧能勾,归档与删除本来就该收下它们。迁移那一侧因此自己把隐藏的摘出去:宿主对点名的隐藏会话一律拒(工具那一路点名它们时,计划会如实列出这条拒绝),所以页面只把看得见的那些送进计划,弹窗里多一句"另有 N 条勾中的会话这次不搬"并写明下一步(已归档的取消归档、空白的删掉、子智能体连着它的父会话一起迁)——说会搬几条就搬几条,不会因为混进一条而整次落空。它们因此搬不走,而归属还在源工作区里,那块工作区就不会被删——预演与结果会点名「还剩哪几条、因此不会被删」(工具返回值的 stranded);登记了、盘上却已经没有的那些(插件删除会话时刻意不清注册表留下的悬空登记)不算成员:宿主本来就不认它们,判「这块工作区搬空了吗」时也不再数它们,并在这一次里顺手从注册表摘掉; - 宿主正攥着的会话搬不动:宿主内存里那条(插件列表里带「活动中」标签的那些)还没放下旧路径,挂靠会被它回绝,日志也不能搬走——计划会把这批摘出去,弹窗里多一句「另有 N 条宿主持在内存里,这次不搬」,它们留在原目录、归属不变。重启 DSH 之后再迁一次即可把剩下那几条收进来;点名点到它们、或勾中的每一条都还活着时,计划如实拒绝(不是「没选中会话」); - 子智能体跟着父会话走:勾一条父会话,它的子智能体会话(连同更深的那些)一起搬(归档与导出同样:勾父会话就把子智能体一起归档 / 一起装进包)——各自的日志 header 改写 cwd、各自的会话目录搬进目标项目目录,一个备份装整族(否则族会被拆到两个目录里,而子智能体本身是搬不动的:它自己勾不动,点名它又是向上的牵连)。分叉出来的会话不算(它自洽、父搬走也照样能打开)。搬完成员资格不变:子智能体本来不算工作区成员,搬完还是不算;计划里会报「其中 N 条是子智能体会话」; - 点「迁移」开弹窗,里面是:目标目录那一个字段(以及新建工作区名称、是否连带产物)+逐条列出会被搬走的会话(子智能体跟着走的那几条缩进一级、挂「随父迁」)、会话数、日志数、字节数、来源项目目录 → 目标项目目录、注册表会怎么变(新建还是复用目标工作区、写进注册表几条、从哪些工作区搬出、是否移除已空的工作区、顺带摘掉几条悬空登记)、产物计划与跳过原因;搬不走的(宿主持着的、侧边栏不显示的)各多一句说明——它们留在源工作区里,那块工作区因此不会被删; - 按「确认迁移」:改写每个日志 header 的 cwd(只动首帧,其余字节不变)→ 搬会话目录 → 写回注册表 → 交给宿主自己改(复用 / 新建目标工作区、把会话挂过去、摘空就删,见下面「注意事项」)→ 独立复核 (等价于宿主的 corrupt 判据,另加一条「会话 id 不变」的复核)→ 留下字节级备份;完成后页面会告诉你这次改动是不是即时生效,或者需要重启 DSH。 传输——把会话带走,再带回来 - 列表里的会话显示的是标题(鼠标悬浮才给出完整标题与 id):uuid 对人是零信息,认会话靠的是「我上次问那个问题的会话」;读不到标题的老会话回落显示 id。导出列表与「会话」页照单全收、连侧边栏不显示的会话(子智能体 / 空白 / 已归档)也列出来——「这条会话要不要带走 / 要不要删」是人的决定,替你预先藏起来只会让「我明明有这条会话」变成找不着的谜;迁移则相反(见下); - 导出:勾选会话 → 浏览器下载一个 .dshsess 包。包里是这些会话所有代次日志的原始字节 (逐条带 sha256),不含会话创建过的普通文件。列表按目录分组(组名是工作区名称;没加入工作区的目录拿这个目录的项目身份最后一段当组名——有 git remote 就是它,没有才退回显示路径,两种情况都标出来;身份与本机路径都在组名那一格的悬浮提示里,认得出身份的还挂一枚主机名小标签), 组头那一下就是整组勾选 / 取消——「把这个工作区的会话都带走」因此是一次点击;组头最前那个尖角是折叠(收起只把组内的行收起来,组头与「这组几条 / 勾了几条」都还在),列表上方那行「按目录分组」后面是全部收起 / 全部展开:收起是显示上的事,「全选」照旧按列出来的那些算,不会因为点了箭头就悄悄少导出几条;两级一眼分得开:组头是带底色的横幅 + 文件夹图形,会话行缩进挂在它下面、挂对话气泡图形(会话名称是用户写的一句话,很容易长得像个目录名,靠读字分不出来)。子智能体会话再往里缩一级、挂在它父会话下面(列表里的父子与「删 / 搬父会话会带上谁」是同一棵树);侧边栏「未分组」那一组里的会话在这里跟着目录走,行上挂一枚「未分组」小标签说明这件事。 - 筛选:与「会话」页同一套——一排小胶囊(子智能体 / 空白 / 已归档 / 未分组 / 活动中,各带库里的条数,多选=任一命中,「全部」一键清空)加一个标题 / id 搜索框;两者一起用时是「与」(搜 foo 且只看空白)。 筛过之后头部报「显示 N / M 条」,「全选」选的是眼下列出来的那些(旁边常驻的「清空」清的是整个勾选集);筛空的那一组整组不画(组头底下没有行,看着像坏了),组头报的也是筛剩下的条数; - 导入:选包 + 选目标目录——那一个字段与迁移的目标目录同一个控件、同一份候选(值控件点开候选面板,也能手输路径或去文件系统里挑)→ 点「导入」,弹窗里逐条列出会创建什么、cwd 会被改写成什么、哪些会被跳过、注册表会怎么变 → 确认才落盘(全是跳过时确认按钮是灰的)。导出不问:它只打包下载,不改本机任何东西。导入永不覆盖:库里已有同 id 的会话只跳过并报告;包里没有 cwd 的会话落 _no-cwd 项目目录,也不进注册表。 备份——那几个会写盘的操作共同的后悔面:列出本插件写过的每一份备份(时间、迁移 / 删除 / 覆盖前、会话数、源 → 目标),点「回滚」/「恢复」先在弹窗里摆出动作清单,确认才动。迁移留下的那份点 回滚:把会话目录、日志字节与工作区注册表一起还原(空掉的目标项目目录也会删掉,与迁移清理空源项目目录对称)。删除与同步覆盖本机那份留下的点恢复:只把备份里那份搬回原位——那两个操作从头到尾没碰过注册表。 说明——把名词与代价一次讲清:分类词典(可见 / 子智能体 / 空白 / 已归档 / 活动中 / 未分组,其中「未分组」就是侧边栏那一组:不属于任何工作区、而且侧边栏会显示它)、每页做什么、数据从哪来(会话库与注册表两条路径),以及常见疑问(「未分组」到底算哪些会话、删完侧边栏为什么还在、迁移什么时候要重启、活动中的会话怎么关掉、备份与「回滚」「恢复」的分工、子智能体跟着父会话走、包里有什么)。动作页(会话 / 传输 / 同步 / 备份)上只留「当下要做的决定」,那些词条与边界条件都在这页上,所以那几页每段说明都不超过两行。 宿主没有 webServer 服务时(例如只用工具的前端)这一页不出现,工具照常可用。 页面右下角写着这一页是哪个构建:发布版只有版本号(v0.3.1);从仓库直接 build 出来的产物多一个短 commit(v0.3.1 · 0b8c452),构建时工作区有未提交改动再挂 -dirty。悬浮提示把这句话说全(是发布版还是本地构建)。 同步(WebDAV) 多台机器上的同一个项目往往装在不同目录(/home/alice/dev/proj 与 /opt/work/proj),所以不能直接同步会话库:库里的目录名与 header 的 cwd 绑死,换台机器就对不上。这一块的做法是把远端当中转——远端放的是 .dshsess 包,落地仍然走导入那条路,由它把 cwd 改写成这台机器的路径。 配置(插件配置里的 sync 块): sync: url: https://dav.example.com/dsh # WebDAV 地址(服务器根也行);插件在它下面自建 dsh-session-manager/ machineId: robot-a # 可选,缺省取主机名;两台机器别用同一个 id username: alice # 可选(Basic) passwordRef: DSH_DAV_PASSWORD # 可选:凭据引用名(环境变量名);留空就用 DSH_DAV_PASSWORD mapping: # 显式映射:远端 cwd → 本机目录(两台机器各写各的) /home/alice/dev/proj: /opt/work/proj timeoutMs: 30000 # 可选 autoPush: true # 可选:自动推送(写完一轮就推、只推不拉,见下面那一段) autoPushIntervalMs: 300000 # 可选:兜底间隔毫秒,缺省 300000(5 分钟),最小 60000 这一节也能在界面里改:设置 →「会话管理」→「同步」分页(这张表单在卡片里),URL、机器名、账号、密码、超时与映射表都能直接编辑(留空的字段按框里那份灰字生效:机器名留空就是主机名,超时留空就是 30000 毫秒),按「保存」写进 profile 那份配置文档(~/.dsh/profiles/<name>/cordis.patch.yml)。sync 是活字段,改完不用重启就生效;三个路径字段(sessionsRoot / registryPath / backupRoot)仍然只在配置文件里。路径映射在表单里逐行增删:左边是远端记下的 cwd,右边是这台机器上的目录;远端那一栏必须逐字对上别的机器记下的路径,所以同步过一次(弹窗里那份计划见过它们)之后它会把这些 cwd 列成候选,不用手抄。 自动推送——只推不拉:勾上它,插件就在本机写完一轮时自己推一轮(宿主那边一条会话的 turn/end 到了就动手),只把本机的会话推到远端,一个字节都不拉:不拉取、不覆盖本机那份、不备份、不写注册表,也就不会动本机任何一条会话。这是一条安全边界,不是"跑得轻一点的同步"——拉取那一侧要动本机已有内容(远端更新时先备份再顶掉本机那份),那件事得有人看着,界面上它前面还挡着一个确认弹窗,所以后台这一路只做纯增量的那一半。两栏在「同步设置」表单里填(超时下面): sync: autoPush: true # 打开自动推送(缺省不推送) autoPushIntervalMs: 300000 # 兜底间隔毫秒,缺省 300000(5 分钟),最小 60000(1 分钟) 推的时机是最后一个会话事件之后安静 10 秒:一轮对话不是只发一次 turn/end(自动续轮、子智能体起停都会各发一次),先攒一攒,所以一串事件只会推一次,不会把整包连发好几遍。这一路绕开下面那道速度闸——你刚写完一轮,"距上次才过了 20 秒"不该挡住它。 autoPushIntervalMs 是兜底间隔,不是"每 5 分钟才推一次":它管的是事件层够不到的那三类——别的机器推上去的东西(本机一个字节都没写,也就不会有事件)、本机删了会话要收尾索引(同样没有新事件)、事件层没接上(DSH 崩过、没拿到会话服务)。所以它的意思是"最长多久没推就一定补一次"。小于 60000 的值会被抬到 60000(想的是"勤快点"不必报错,但也不让一个 0 把远端打爆);间隔改完不用重启,下一轮就按新值走,关掉也是立刻停。远端更新的那些不会被拉下来:它只推,所以别的机器的改动要到你手动点「同步」、在弹窗里确认那一步才会落本机。卡片底部那枚「立即推送一次」(和「同步」同一行,开关开着时才出现)就是当场跑一轮——它也绕开速度闸,因为你按的那一下不该被"距上次才过了 20 秒"挡住;它仍然只推不拉,所以没有确认弹窗,进度画在页面横幅上。这一趟跑没跑、为什么会没跑(没开 / 兜底那轮还没到点 / 上一轮还在跑 / 没有要推的)都在横幅里说清,连续失败也会报一句。 密码在那张表单里直接填:那是个只写的输入框,按「保存」把密码写进宿主机凭据库($DSH_HOME/.credentials.yaml),配置文件里始终只有引用名——值不回显,界面只报「已配置 / 未配置」。引用名不在这张表单上:它就是配置里的 sync.passwordRef(缺省 DSH_DAV_PASSWORD),要换名字(或者本来就用环境变量)在插件配置页那份通用表单里改。启动环境里已有的同名变量会遮住写入,这时界面把密码框标成不可改。 配完先点一次「测试连接」:它只读探一次远端(两次 PROPFIND,不改远端任何东西),回的是一句结论——连得上/认证过不过/地址对不对,认证失败还会分清是「没填用户名」「引用名里没有值」还是「服务器不认这套用户名密码」。测的是已保存的配置(宿主的运行时按配置现读),所以有未保存的改动时按钮是禁用的。写权限不在这里验:第一次同步自然会建那一层,测试不该往别人的服务器上放东西。 多机共用一份配置:远端索引里每条会话还记着项目身份——仓库的 git remote(规范化成 host/owner/repo)与会话 cwd 在仓库根之下的相对路径。拉取的时候先用本机的候选目录(会话的 cwd + 注册表里记着的工作区路径)认一遍身份,认出同一个仓库就落 本机仓库根 + 相对路径。于是几台机器可以用字面一样的配置(url 相同、machineId 缺省取主机名、mapping 留空),同一个仓库克隆在 /home/alice/dev/proj 与 /opt/work/proj 也照样对得上;反过来,两台机器上同名的目录若是两个仓库,不会被认成同一个项目。没装 git、不是仓库、没有 remote、本机没这个项目——一律退回下面那张映射表,计划里会点名是哪个仓库没认出来。 mapping 与身份共存:显式映射优先(配了就按配的落),身份是自动那条路。所以非 git 目录、或者想强制落到别处,仍然靠映射。 远端布局:插件在 url 下面自建一层自己的命名空间,dsh-session-manager/<machineId>/index.json 是这台机器贡献了哪些会话,dsh-session-manager/<machineId>/<id>.dshsess 是一条会话一个包——所以 url 可以直接填 WebDAV 的服务器根或账号根,不必自己写这一层。一机一格:WebDAV 没有锁,每台机器只写自己那一格、读别人的全部,就不会互相盖掉。 在「同步」分页:点「同步」先在弹窗里算一份只读计划(读远端,什么都不写),报出「会拉取 N 条 / 会推送 M 条」,三张表(会拉取 / 会推送 / 两边都有、这次不动)各自按项目目录分组——组头是这一组的名字(工作区标题,没加入工作区就是项目名)+ 一枚主机名小标签 + 这一组几条,项目身份与本机路径在悬浮提示里;行里只剩动作、会话名与大小,会话名后面跟着与会话列表同一套的类型标签(子智能体 / 空白 / 已归档 / 活着的,本机已有的那几条才挂得上)。「这次不动」那张表的第三列是哪台机器持有的另一份。按「确认同步」才真的拉取与推送,而确认用的是预演算出来的那份数据(不再把同一份计划重算一遍):只有预演之后本机或远端又变了才会从零重算,那时成功那句话里会说明;落地那一段弹窗正文换成进度条与「正在推送 12 / 84」,下面是当前那一条的标题——分母是这一段真正会做的条数(跳过的那些不算),拉取与推送各自走一遍。规则与边界: - 两边都有同一个 id 时,按内容与「最后活动时间」择新:内容逐代一致(cwd 之外)就不动;本机确实是远端那份的前缀就重推刷新远端;远端领先(本机这份是它的前缀)或两边各自写过而远端更晚,就先备份本机那份、再换成它(旧的那份随时能从「备份」页里恢复);有一边读不到最后活动时间(老索引 / 宿主没挂投影缓存)或者两边一样新,就两条都不动,报告里说清是「远端更新」还是「两边各自写过」。「最后活动时间」取宿主从日志内容里折出来的两枚钟里晚的那枚(最后一次提问、最后一条消息——agent 自己写进去的也算,所以"推送之后本机又跑了几轮"照样分得出高下),复制、改写 cwd 都不影响它。判据是代次指纹, 与 cwd 无关——落地一定会改写别人的 cwd(库目录名与 header 的 cwd 绑死),按字节比会把拉取来的那份判成"两边各自写过",连"同步下来继续写"都无法再推送回远端; - 本机严格领先时重推:两边共有的代次内容一致(cwd 之外逐条一致),说明远端那份确实是本机这份的前缀,刷新它不会丢任何代次;同一 id 有多台贡献时,拉取的一侧取领先的那份而不是格子名排前面的那份; - 空白会话不参与同步:建出来但一个回合都没聊过的会话不推送(正文里报一句「跳过 N 条空白会话」),下一次推送还会把它从自己那格索引里撤下来——别的机器因此不再看得到它;本机这条是空白而远端有内容时以远端为准(空的那份没什么可保护的); - 删掉的会话下一次推送就从自己那份索引里消失(远端那份包不主动删),别的机器已取走的副本不受影响; - 没有 cwd 的会话不硬塞路径,按 _no-cwd 落地(与导入同一条口径);没配映射的 cwd 会被跳过并列出来; - 拉取来的会话要宿主重新扫描才会出现在侧边栏:改完注册表之后插件把活儿交给宿主自己做(与迁移同一套语义),宿主上没有那套动作时才提示重启——那时确认弹窗里就会先说,落地之后页面上再单独留一条警告(宿主自己接得住时一个字都不说)。 同步也要跑 plan 那一套:工具 sync_sessions 默认只预演,apply:true 才落盘。 模型工具 | 工具 | 写盘 | 说明 | | plan_session_migration | 否 | 只读计划:会话/文件数、目标项目目录、注册表变更、阻塞问题 | | migrate_sessions | 需 apply:true | 默认 dry-run;执行前做字节级备份,事后自动复核 | | rollback_session_migration | 是 | 按备份目录字节级回滚 | | verify_workspace_sessions | 否 | 复核某项目目录内日志与 header 的一致性 | | sync_sessions | 需 apply:true | 与 WebDAV 远端同步(拉取别人推送的、推送本机独有的;两边都有同 id 时按内容与最后活动时间择新);默认 dry-run | 注意事项 - 改完注册表交给宿主自己做,正常不必重启:磁盘上的 workspace.json 只是第三份拷贝——宿主内存里那份才是权威(写入才改它,绝不从磁盘重读),它另有一份启动时建的 header 索引。所以插件改完注册表之后不是 "等宿主自己发现",而是把活儿交给宿主自己做:先让它按磁盘重看一眼(刷新 header 缓存与"这条会话住哪儿" 的索引),再复用 / 新建目标工作区、把会话挂过去、把源侧摘空、删掉已空的工作区。这几件事宿主自己会落盘、也会通知界面,所以侧边栏立刻按新归属显示——不动进程,也不杀正在跑的回合。会话 id 全程只被挂靠与摘除,一个都没变;已存在的工作区也是复用自己那条记录,id 不变。 宿主上没有那套动作时(只有工具没有界面的前端、老版本),工具与页面都会如实说 需要重启 DSH,并且 重启前别改任何工作区——新建 / 改名 / 归档都会用内存副本把这处改动覆盖掉。宿主中途拒绝时也一样:插件会把这次算好的注册表整份写回文件(宿主只要动过手就已经拿内存副本盖过一次),所以重启后看到的是这次的结果,不是它那份旧归属。 - 落地之后顺手把列表元数据折好:侧边栏那一行的标题、空白判据与最后活动时间都不来自日志,而是宿主自己那份投影检查点——它只在活着的会话上被写。所以导入、同步拉取、迁移(改写了 cwd)落地之后,插件会借宿主自己的两个服务(读完整日志 + 冷读投影)把这些会话各补一次,侧边栏因此不用逐条点开就有名字。代价是每条落地会话多读一遍日志;读不动的逐条跳过并如实报条数,宿主上没有那套动作时那几条要等你第一次点开。 - 先看不写:工具与页面上的计划(弹窗里那份)都不写任何字节;每次真正写盘前都会先留一份字节级备份。 - 同步会按「谁更新」择新:远端那份更新(或两边各自写过而它更晚)时会先备份本机那份、再换成它;有一边读不到最后活动时间就两条都不动(见「同步」那一节的边界)。 - 只动首帧:改写只重新压缩 header 那一帧,其余 frame 字节原样保留,回滚可逐字节还原(连同可选搬迁的会话产物)。 - 路径:会话 cwd 与注册表路径都不带结尾斜杠;projectKey 会把 /、\、: 折叠成 -,极少数路径会因此编码碰撞,这种情况计划层会直接拦下。 - 插件配置:sessionsRoot / registryPath / backupRoot 三个可选字段可覆盖上述默认路径;sync 块配 WebDAV 同步(url / machineId / username / passwordRef / mapping / timeoutMs)。密码只放引用(环境变量名),配置文件里没有明文:界面上直接填的密码进的是宿主机凭据库($DSH_HOME/.credentials.yaml),不是这份配置。 文档 - .agents/notes/——每条决策的依据与被放弃的做法(中文):多帧 zstd 的静默丢数据、启动不变式、有损编码的碰撞、.dshsess 容器的取舍、生效模式、同步怎么判「谁更新」、空白会话为什么不搬,以及界面上的那些取舍 - docs/internals.md——决策地图:按主题索引到上面那些笔记 - docs/development.md——本地流程:依赖、构建、测试、开发实例 - docs/releasing.md——发版流程 许可证 MIT
预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
he0119/dsh-session-manager
迁移会话目录、导入导出会话包并跨机同步
相关插件 · 市场 / 管理
插件精选列表awesome-dsh-plugin/awesome-dsh-plugin
DeepSeek Harness(dsh)插件精选列表
★ 21,780未汉化DSH
73.5
新词雷达zhuyansen/new-term-radar
new-term-radar新词雷达
★ 1,947已汉化MCP
72.7
插件生态导航0xsline/awesome-deepseek-harness
安装 贡献指南 DeepSeek Docs 公开插件目录 Issues
★ 1,188已汉化DSH
72.6
技能注册中心iflytek/skillhub
企业级开源智能体技能注册中心——发布、发现并管理组织内可复用的技能包。
★ 5,347已汉化DSH
71.9
插件精选清单Anil-matcha/awesome-dsh-plugin
DeepSeek Harnessdsh精选指南——DeepSeek…
★ 1,013已汉化DSH
71.7
插件市场目录imsai-sh/awesome-deepseek-harness-plugins
面向 DeepSeek Harnessdsh生态的社区插件目录,共收录 13917 个插件含 PR 收录与…
★ 343已汉化DSH
71.6