@weibaohui/dsh-sync 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # @weibaohui/dsh-sync
2
2
 
3
- [![DSH plugin](https://img.shields.io/badge/dsh-plugin-✅-green)](https://github.com/topics/dsh-plugin)
3
+ [![DSH plugin](https://img.shields.io/badge/dsh-plugin-green)](https://github.com/topics/dsh-plugin)
4
4
  [![npm version](https://img.shields.io/npm/v/@weibaohui/dsh-sync)](https://www.npmjs.com/package/@weibaohui/dsh-sync)
5
5
 
6
6
  **多机同步插件**:让多台机器上的 dsh 通过一个私有 GitCode 仓库保持一致——技能、会话、设置、插件清单都能同步。
7
7
 
8
+ ![多机同步:仓库配置、同步开关与冲突处理](docs/demo.gif)
9
+
8
10
  ## 核心功能
9
11
 
10
12
  - **四类内容可同步**(各有独立开关):
@@ -13,7 +15,9 @@
13
15
  - 设置(settings.yaml,默认开)
14
16
  - 插件清单(各 profile 的依赖与配置,默认开)
15
17
  - **分支 → PR → 合并**:每台机器的变更以 PR 形式提交,冲突显化为一个待合并的 PR,绝不静默覆盖
16
- - **AI 一键解决冲突**:出现冲突时界面冒出「AI 解决冲突」按钮,点击后自动分析两边改动并合并,确定性的 git 操作不用你动手
18
+ - **同步前自动回填**:每次推送前先把远端新增、本机没动过的内容拉回本机,本机快照不会误删别的机器推上来的新技能/新配置
19
+ - **AI 智能对齐**:点「AI 智能对齐」,先自动回填远端新增,再由 AI 对两边都改过的文件做语义合并(动手前自动备份本机文件),合并后自动推送;仍冲突的 PR 顺手解决
20
+ - **AI 一键解决冲突**:出现冲突时设置页冒出「AI 解决冲突」按钮,点击后自动分析两边改动并合并,确定性的 git 操作不用你动手
17
21
  - **安全**:强制私有仓库(公共仓库直接拒绝保存);访问 token 只写不回读
18
22
  - **拉取安全**:pull 只回写本地没动过的远端变更,本地改过的内容不会被覆盖
19
23
 
@@ -31,4 +35,8 @@ dsh plugin --profile web add @weibaohui/dsh-sync -w
31
35
  2. 打开 Web UI → **设置页 → dsh-sync**,填入仓库地址与 access token,保存
32
36
  3. 按需开关四类同步内容
33
37
  4. 之后每次修改,通过同步操作把本机变更推成 PR;多机之间即可保持一致
34
- 5. 出现冲突时,会话界面会出现「AI 解决冲突」按钮,点一下即可
38
+ 5. 日常可点「AI 智能对齐」让 AI 先回填远端新增、语义合并双方改动;出现冲突时设置页会出现「AI 解决冲突」按钮,点一下即可
39
+
40
+ ## 联系我 :飞书群
41
+
42
+ ![link](https://foruda.gitee.com/images/1774880015525784725/4fd67005_77493.png "link")
package/client/bundle.js CHANGED
@@ -109,6 +109,13 @@ window.__ModuleLoader__.load({
109
109
  conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
110
110
  conflictPending: '有未解决的冲突 PR',
111
111
  resolveBtn: 'AI 解决冲突',
112
+ alignBtn: 'AI 智能对齐',
113
+ alignTitle: 'AI 智能对齐',
114
+ alignHint: '先自动拉回远端新增内容(不覆盖本机改动),再由 AI 语义合并两边都改过的文件;动手前自动备份本机文件,合并后自动推送,仍冲突的 PR 会顺手解掉。',
115
+ alignFiles: '待语义合并文件',
116
+ alignNoFiles: '无双方改动——确定性同步已处理全部差异',
117
+ alignBackup: '备份目录',
118
+ reconcileLine: '远端回填 {applied} 项 · 待 AI 对齐 {both} 项',
112
119
  openChat: '打开对话',
113
120
  running: '执行中…(可能需要几分钟)',
114
121
  runDone: '解决完成',
@@ -154,6 +161,13 @@ window.__ModuleLoader__.load({
154
161
  conflictHint: 'An unmerged sync PR exists (two machines edited the same file). Click below: the AI reads the local token, analyzes both sides, resolves the conflict and merges the PR.',
155
162
  conflictPending: 'Unresolved conflict PR',
156
163
  resolveBtn: 'AI resolve conflict',
164
+ alignBtn: 'AI align',
165
+ alignTitle: 'AI smart align',
166
+ alignHint: 'Pulls remote-only changes first (never overwrites local edits), then the AI semantically merges files both sides changed; live files are backed up before merging, the result is pushed automatically, and any still-conflicting PR is resolved in the same run.',
167
+ alignFiles: 'Files to semantically merge',
168
+ alignNoFiles: 'No both-side changes — deterministic sync handled everything',
169
+ alignBackup: 'Backup dir',
170
+ reconcileLine: 'pulled {applied} remote items · {both} awaiting AI align',
157
171
  openChat: 'Open chat',
158
172
  running: 'Running… (may take minutes)',
159
173
  runDone: 'Resolved',
@@ -259,28 +273,33 @@ window.__ModuleLoader__.load({
259
273
  return h('div', { className: 'sk-toast' }, text)
260
274
  }
261
275
 
262
- // ── Conflict-resolution dialog: AI action button + streamed output ──
263
- // (mirrors the skills-management share-run polling pattern)
276
+ // ── Agent-run dialog: action-button pattern for two modes ──
277
+ // conflict: user opens it, then clicks run (needs pending PR info)
278
+ // align: parent already POSTed (which ran a deterministic sync first),
279
+ // dialog opens with the job streaming and the both-modified list
280
+ // Both poll the same shape of job endpoint and can open the agent session.
264
281
 
265
- function ConflictDialog({ t, pending, onClose, onToast }) {
266
- const [job, setJob] = useState(null)
282
+ function AgentRunDialog({ t, mode, pending, initial, onClose, onToast }) {
283
+ const align = mode === 'align'
284
+ const endpoint = align ? API + '/align/run' : API + '/conflict/run'
285
+ const [job, setJob] = useState(initial && initial.jobId ? { jobId: initial.jobId, status: 'running', output: '', code: null } : null)
267
286
  const [busy, setBusy] = useState(false)
268
287
  useEffect(() => {
269
288
  if (job === null || job.status !== 'running') return
270
289
  const timer = setInterval(() => {
271
- getJson(API + '/conflict/run?id=' + encodeURIComponent(job.jobId))
290
+ getJson(endpoint + '?id=' + encodeURIComponent(job.jobId))
272
291
  .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
273
292
  .catch(() => {})
274
293
  }, 2000)
275
294
  if (typeof timer.unref === 'function') timer.unref()
276
295
  return () => clearInterval(timer)
277
- }, [job && job.status])
296
+ }, [job && job.status, endpoint])
278
297
  const doRun = async () => {
279
298
  setBusy(true)
280
299
  try {
281
- const r = await fetch(API + '/conflict/run', {
300
+ const r = await fetch(endpoint, {
282
301
  method: 'POST', headers: { 'Content-Type': 'application/json' },
283
- body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
302
+ body: JSON.stringify(align ? {} : { branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
284
303
  })
285
304
  const d = await r.json().catch(() => ({}))
286
305
  if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
@@ -295,12 +314,19 @@ window.__ModuleLoader__.load({
295
314
  }
296
315
  const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
297
316
  h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
298
- return h(SkDialog, { title: t('conflictTitle'), onClose, wide: true },
317
+ const files = initial && Array.isArray(initial.bothModified) ? initial.bothModified : null
318
+ return h(SkDialog, { title: align ? t('alignTitle') : t('conflictTitle'), onClose, wide: true },
299
319
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
300
- h('div', { className: 'sk-hint' }, t('conflictHint')),
301
- pending && h('div', null,
320
+ h('div', { className: 'sk-hint' }, align ? t('alignHint') : t('conflictHint')),
321
+ !align && pending && h('div', null,
302
322
  row('PR', '#' + (pending.prNumber || '-')),
303
323
  row('Branch', pending.branch || '-')),
324
+ align && h('div', { className: 'sk-card' },
325
+ h('div', { className: 'sk-dir', style: { marginBottom: 4 } }, t('alignFiles')),
326
+ files && files.length
327
+ ? h('pre', { style: { margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, maxHeight: 120, overflow: 'auto' } }, files.join('\n'))
328
+ : h('div', { className: 'sk-hint' }, t('alignNoFiles')),
329
+ initial && initial.backupDir && row(t('alignBackup'), initial.backupDir)),
304
330
  job !== null && h('div', null,
305
331
  h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
306
332
  t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
@@ -308,7 +334,7 @@ window.__ModuleLoader__.load({
308
334
  job.output || '…')),
309
335
  h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
310
336
  job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
311
- h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
337
+ !align && h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
312
338
  job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
313
339
  }
314
340
 
@@ -317,7 +343,10 @@ window.__ModuleLoader__.load({
317
343
  function SettingsSection({ t }) {
318
344
  const [status, setStatus] = useState(null)
319
345
  const [busy, setBusy] = useState(false)
346
+ const [alignBusy, setAlignBusy] = useState(false)
320
347
  const [conflictOpen, setConflictOpen] = useState(false)
348
+ const [alignOpen, setAlignOpen] = useState(false)
349
+ const [alignInitial, setAlignInitial] = useState(null)
321
350
  const [toastText, setToastText] = useState(null)
322
351
  const [repoUrl, setRepoUrl] = useState('')
323
352
  const [branch, setBranch] = useState('')
@@ -356,6 +385,20 @@ window.__ModuleLoader__.load({
356
385
  } catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
357
386
  finally { setBusy(false); refresh() }
358
387
  }
388
+ // AI smart align: POST runs a deterministic sync first (remote-only files
389
+ // pulled back), then streams the semantic-merge agent run; dialog opens
390
+ // already running with the both-modified file list.
391
+ const doAlign = async () => {
392
+ setAlignBusy(true)
393
+ try {
394
+ const r = await fetch(API + '/align/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
395
+ const d = await r.json().catch(() => ({}))
396
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
397
+ setAlignInitial({ jobId: d.jobId, bothModified: d.bothModified || [], backupDir: d.backupDir })
398
+ setAlignOpen(true)
399
+ } catch (e) { onToast(e.message || t('operationFailed'), 4000) }
400
+ finally { setAlignBusy(false); refresh() }
401
+ }
359
402
  const putSettings = async (patch) => {
360
403
  const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
361
404
  const d = await r.json().catch(() => ({}))
@@ -405,6 +448,13 @@ window.__ModuleLoader__.load({
405
448
  row(t('branchLabel'), status.branch || '-'),
406
449
  row(t('dirLabel'), status.dir),
407
450
  row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
451
+ (() => {
452
+ const rec = status.lastResult && status.lastResult.reconcile
453
+ const applied = rec && Array.isArray(rec.applied) ? rec.applied.length : 0
454
+ const both = rec && Array.isArray(rec.bothModified) ? rec.bothModified.length : 0
455
+ if (!rec || (!applied && !both)) return null
456
+ return h(Tag, { tone: both ? 'danger' : 'accent' }, t('reconcileLine', { applied, both }))
457
+ })(),
408
458
  h('div', null,
409
459
  h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
410
460
  h('div', { className: 'sk-toggles' },
@@ -436,7 +486,12 @@ window.__ModuleLoader__.load({
436
486
  h('div', { className: 'sk-toolbar' },
437
487
  h(ButtonLite, { onClick: doSave }, t('save')),
438
488
  h('span', { className: 'sk-spacer' }),
439
- h(ButtonLite, { primary: true, disabled: busy || status.syncing, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
489
+ h(ButtonLite, {
490
+ disabled: alignBusy || status.syncing || !status.repoUrl || !status.hasToken,
491
+ title: !status.repoUrl || !status.hasToken ? t('notConfigured') : undefined,
492
+ onClick: doAlign,
493
+ }, alignBusy ? t('running') : t('alignBtn')),
494
+ h(ButtonLite, { primary: true, disabled: busy || status.syncing || alignBusy, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
440
495
  } catch (renderErr) {
441
496
  ;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
442
497
  body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
@@ -445,8 +500,11 @@ window.__ModuleLoader__.load({
445
500
 
446
501
  return h('div', { className: 'sk-page' },
447
502
  h('div', { className: 'sk-body' }, body),
448
- conflictOpen && status && status.pendingConflict && h(ConflictDialog, {
449
- t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
503
+ conflictOpen && status && status.pendingConflict && h(AgentRunDialog, {
504
+ t, mode: 'conflict', pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
505
+ }),
506
+ alignOpen && h(AgentRunDialog, {
507
+ t, mode: 'align', initial: alignInitial, onClose: () => setAlignOpen(false), onToast,
450
508
  }),
451
509
  toastText && h(InToast, { text: toastText }),
452
510
  )
package/client/index.js CHANGED
@@ -99,6 +99,13 @@ const ZH = {
99
99
  conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
100
100
  conflictPending: '有未解决的冲突 PR',
101
101
  resolveBtn: 'AI 解决冲突',
102
+ alignBtn: 'AI 智能对齐',
103
+ alignTitle: 'AI 智能对齐',
104
+ alignHint: '先自动拉回远端新增内容(不覆盖本机改动),再由 AI 语义合并两边都改过的文件;动手前自动备份本机文件,合并后自动推送,仍冲突的 PR 会顺手解掉。',
105
+ alignFiles: '待语义合并文件',
106
+ alignNoFiles: '无双方改动——确定性同步已处理全部差异',
107
+ alignBackup: '备份目录',
108
+ reconcileLine: '远端回填 {applied} 项 · 待 AI 对齐 {both} 项',
102
109
  openChat: '打开对话',
103
110
  running: '执行中…(可能需要几分钟)',
104
111
  runDone: '解决完成',
@@ -144,6 +151,13 @@ const EN = {
144
151
  conflictHint: 'An unmerged sync PR exists (two machines edited the same file). Click below: the AI reads the local token, analyzes both sides, resolves the conflict and merges the PR.',
145
152
  conflictPending: 'Unresolved conflict PR',
146
153
  resolveBtn: 'AI resolve conflict',
154
+ alignBtn: 'AI align',
155
+ alignTitle: 'AI smart align',
156
+ alignHint: 'Pulls remote-only changes first (never overwrites local edits), then the AI semantically merges files both sides changed; live files are backed up before merging, the result is pushed automatically, and any still-conflicting PR is resolved in the same run.',
157
+ alignFiles: 'Files to semantically merge',
158
+ alignNoFiles: 'No both-side changes — deterministic sync handled everything',
159
+ alignBackup: 'Backup dir',
160
+ reconcileLine: 'pulled {applied} remote items · {both} awaiting AI align',
147
161
  openChat: 'Open chat',
148
162
  running: 'Running… (may take minutes)',
149
163
  runDone: 'Resolved',
@@ -249,28 +263,33 @@ function InToast({ text }) {
249
263
  return h('div', { className: 'sk-toast' }, text)
250
264
  }
251
265
 
252
- // ── Conflict-resolution dialog: AI action button + streamed output ──
253
- // (mirrors the skills-management share-run polling pattern)
266
+ // ── Agent-run dialog: action-button pattern for two modes ──
267
+ // conflict: user opens it, then clicks run (needs pending PR info)
268
+ // align: parent already POSTed (which ran a deterministic sync first),
269
+ // dialog opens with the job streaming and the both-modified list
270
+ // Both poll the same shape of job endpoint and can open the agent session.
254
271
 
255
- function ConflictDialog({ t, pending, onClose, onToast }) {
256
- const [job, setJob] = useState(null)
272
+ function AgentRunDialog({ t, mode, pending, initial, onClose, onToast }) {
273
+ const align = mode === 'align'
274
+ const endpoint = align ? API + '/align/run' : API + '/conflict/run'
275
+ const [job, setJob] = useState(initial && initial.jobId ? { jobId: initial.jobId, status: 'running', output: '', code: null } : null)
257
276
  const [busy, setBusy] = useState(false)
258
277
  useEffect(() => {
259
278
  if (job === null || job.status !== 'running') return
260
279
  const timer = setInterval(() => {
261
- getJson(API + '/conflict/run?id=' + encodeURIComponent(job.jobId))
280
+ getJson(endpoint + '?id=' + encodeURIComponent(job.jobId))
262
281
  .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
263
282
  .catch(() => {})
264
283
  }, 2000)
265
284
  if (typeof timer.unref === 'function') timer.unref()
266
285
  return () => clearInterval(timer)
267
- }, [job && job.status])
286
+ }, [job && job.status, endpoint])
268
287
  const doRun = async () => {
269
288
  setBusy(true)
270
289
  try {
271
- const r = await fetch(API + '/conflict/run', {
290
+ const r = await fetch(endpoint, {
272
291
  method: 'POST', headers: { 'Content-Type': 'application/json' },
273
- body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
292
+ body: JSON.stringify(align ? {} : { branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
274
293
  })
275
294
  const d = await r.json().catch(() => ({}))
276
295
  if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
@@ -285,12 +304,19 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
285
304
  }
286
305
  const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
287
306
  h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
288
- return h(SkDialog, { title: t('conflictTitle'), onClose, wide: true },
307
+ const files = initial && Array.isArray(initial.bothModified) ? initial.bothModified : null
308
+ return h(SkDialog, { title: align ? t('alignTitle') : t('conflictTitle'), onClose, wide: true },
289
309
  h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
290
- h('div', { className: 'sk-hint' }, t('conflictHint')),
291
- pending && h('div', null,
310
+ h('div', { className: 'sk-hint' }, align ? t('alignHint') : t('conflictHint')),
311
+ !align && pending && h('div', null,
292
312
  row('PR', '#' + (pending.prNumber || '-')),
293
313
  row('Branch', pending.branch || '-')),
314
+ align && h('div', { className: 'sk-card' },
315
+ h('div', { className: 'sk-dir', style: { marginBottom: 4 } }, t('alignFiles')),
316
+ files && files.length
317
+ ? h('pre', { style: { margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, maxHeight: 120, overflow: 'auto' } }, files.join('\n'))
318
+ : h('div', { className: 'sk-hint' }, t('alignNoFiles')),
319
+ initial && initial.backupDir && row(t('alignBackup'), initial.backupDir)),
294
320
  job !== null && h('div', null,
295
321
  h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
296
322
  t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
@@ -298,7 +324,7 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
298
324
  job.output || '…')),
299
325
  h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
300
326
  job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
301
- h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
327
+ !align && h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
302
328
  job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
303
329
  }
304
330
 
@@ -307,7 +333,10 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
307
333
  function SettingsSection({ t }) {
308
334
  const [status, setStatus] = useState(null)
309
335
  const [busy, setBusy] = useState(false)
336
+ const [alignBusy, setAlignBusy] = useState(false)
310
337
  const [conflictOpen, setConflictOpen] = useState(false)
338
+ const [alignOpen, setAlignOpen] = useState(false)
339
+ const [alignInitial, setAlignInitial] = useState(null)
311
340
  const [toastText, setToastText] = useState(null)
312
341
  const [repoUrl, setRepoUrl] = useState('')
313
342
  const [branch, setBranch] = useState('')
@@ -346,6 +375,20 @@ function SettingsSection({ t }) {
346
375
  } catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
347
376
  finally { setBusy(false); refresh() }
348
377
  }
378
+ // AI smart align: POST runs a deterministic sync first (remote-only files
379
+ // pulled back), then streams the semantic-merge agent run; dialog opens
380
+ // already running with the both-modified file list.
381
+ const doAlign = async () => {
382
+ setAlignBusy(true)
383
+ try {
384
+ const r = await fetch(API + '/align/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
385
+ const d = await r.json().catch(() => ({}))
386
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
387
+ setAlignInitial({ jobId: d.jobId, bothModified: d.bothModified || [], backupDir: d.backupDir })
388
+ setAlignOpen(true)
389
+ } catch (e) { onToast(e.message || t('operationFailed'), 4000) }
390
+ finally { setAlignBusy(false); refresh() }
391
+ }
349
392
  const putSettings = async (patch) => {
350
393
  const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
351
394
  const d = await r.json().catch(() => ({}))
@@ -395,6 +438,13 @@ function SettingsSection({ t }) {
395
438
  row(t('branchLabel'), status.branch || '-'),
396
439
  row(t('dirLabel'), status.dir),
397
440
  row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
441
+ (() => {
442
+ const rec = status.lastResult && status.lastResult.reconcile
443
+ const applied = rec && Array.isArray(rec.applied) ? rec.applied.length : 0
444
+ const both = rec && Array.isArray(rec.bothModified) ? rec.bothModified.length : 0
445
+ if (!rec || (!applied && !both)) return null
446
+ return h(Tag, { tone: both ? 'danger' : 'accent' }, t('reconcileLine', { applied, both }))
447
+ })(),
398
448
  h('div', null,
399
449
  h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
400
450
  h('div', { className: 'sk-toggles' },
@@ -426,7 +476,12 @@ function SettingsSection({ t }) {
426
476
  h('div', { className: 'sk-toolbar' },
427
477
  h(ButtonLite, { onClick: doSave }, t('save')),
428
478
  h('span', { className: 'sk-spacer' }),
429
- h(ButtonLite, { primary: true, disabled: busy || status.syncing, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
479
+ h(ButtonLite, {
480
+ disabled: alignBusy || status.syncing || !status.repoUrl || !status.hasToken,
481
+ title: !status.repoUrl || !status.hasToken ? t('notConfigured') : undefined,
482
+ onClick: doAlign,
483
+ }, alignBusy ? t('running') : t('alignBtn')),
484
+ h(ButtonLite, { primary: true, disabled: busy || status.syncing || alignBusy, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
430
485
  } catch (renderErr) {
431
486
  ;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
432
487
  body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
@@ -435,8 +490,11 @@ function SettingsSection({ t }) {
435
490
 
436
491
  return h('div', { className: 'sk-page' },
437
492
  h('div', { className: 'sk-body' }, body),
438
- conflictOpen && status && status.pendingConflict && h(ConflictDialog, {
439
- t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
493
+ conflictOpen && status && status.pendingConflict && h(AgentRunDialog, {
494
+ t, mode: 'conflict', pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
495
+ }),
496
+ alignOpen && h(AgentRunDialog, {
497
+ t, mode: 'align', initial: alignInitial, onClose: () => setAlignOpen(false), onToast,
440
498
  }),
441
499
  toastText && h(InToast, { text: toastText }),
442
500
  )
package/docs/demo.gif ADDED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@weibaohui/dsh-sync",
3
- "version": "0.1.2",
4
- "description": "DeepSeek Harness plugin: a small git-based sync system for multiple dsh replicas. Mirrors skills / sessions / settings / plugins into a private GitCode repository and reconciles replicas through a branch → PR → merge flow, so conflicts surface as pull requests instead of silent overwrites.",
3
+ "version": "0.1.4",
4
+ "description": "dsh 插件 · 多机同步:让多台机器上的 dsh 通过一个私有 GitCode 仓库保持一致——技能、会话、设置、插件清单四类内容各有独立开关;变更走分支 → PR → 合并,推送前自动回填远端新增防误删,支持 AI 智能对齐(语义合并双方改动)与一键解决冲突;强制私有仓库,pull 不覆盖本地改动。",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "dsh",
@@ -30,6 +30,7 @@
30
30
  "files": [
31
31
  "src",
32
32
  "client",
33
+ "docs",
33
34
  "cordis.patch.yml"
34
35
  ],
35
36
  "scripts": {
package/src/index.js CHANGED
@@ -489,6 +489,62 @@ async function runPull(binary, eff, { repoDir, state, logger, roots }) {
489
489
  return { pulled: true, applied, skipped, changed: changed.length }
490
490
  }
491
491
 
492
+ // ── Pre-push reconcile: pull remote changes back into live BEFORE the
493
+ // snapshot push. The push mirror is a full-directory overlay (rm + copy),
494
+ // so without this step a file another machine added — and this replica
495
+ // hasn't pulled yet — gets deleted in the push branch and flaps out of
496
+ // the remote. Remote-only and locally-untouched files are written back
497
+ // here (purely deterministic, never overwrites local work); files both
498
+ // sides changed are reported as `bothModified` for the AI align step /
499
+ // conflict PR. Remote deletions are never mirrored into live. ──
500
+
501
+ async function reconcileRemote(binary, eff, { repoDir, state, logger, roots }) {
502
+ const fs = require('node:fs')
503
+ try { await fs.promises.access(join(repoDir, '.git')) } catch { return { reconciled: false, noShadow: true } }
504
+ const remote = authedUrl(eff.repoUrl, eff.token)
505
+ const spec = syncSpec(eff, roots)
506
+ try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
507
+ if (!/Could not find|doesn't exist|no such|empty/i.test(String(e && e.message))) throw e
508
+ return { reconciled: false, empty: true }
509
+ }
510
+ const hasFetch = await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false)
511
+ if (!hasFetch) return { reconciled: false, empty: true }
512
+ const lastSynced = state.lastSyncedCommit
513
+ if (!lastSynced) {
514
+ // never synced: nothing to diff against; just record the baseline
515
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
516
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
517
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
518
+ return { reconciled: false, firstBaseline: true }
519
+ }
520
+ let changedRaw = ''
521
+ try { changedRaw = await gitExec(binary, ['diff', '--name-only', lastSynced, 'FETCH_HEAD'], repoDir) } catch {}
522
+ const changed = changedRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
523
+ const applied = [] // safely written back to live
524
+ const bothModified = [] // both sides changed → AI align / conflict PR
525
+ const remoteDeleted = [] // gone on remote; live keeps its copy
526
+ for (const p of changed) {
527
+ const livePath = resolveLivePath(spec, p)
528
+ if (!livePath) continue
529
+ let remoteBuf = null
530
+ try { remoteBuf = await gitShowBuf(binary, `FETCH_HEAD:${p}`, repoDir) } catch { remoteBuf = null }
531
+ if (remoteBuf === null) { remoteDeleted.push(p); continue }
532
+ let liveBuf = null
533
+ try { liveBuf = await fsP.readFile(livePath) } catch {}
534
+ let lastSyncedBuf = null
535
+ try { lastSyncedBuf = await gitShowBuf(binary, `${lastSynced}:${p}`, repoDir) } catch { lastSyncedBuf = Buffer.alloc(0) }
536
+ const untouched = liveBuf === null ? (lastSyncedBuf.length === 0) : Buffer.compare(liveBuf, lastSyncedBuf) === 0
537
+ if (!untouched) { bothModified.push({ shadowPath: p, livePath }); continue }
538
+ try { await atomicWriteFile(livePath, remoteBuf); applied.push(p) } catch {}
539
+ }
540
+ // advance shadow baseline to FETCH_HEAD — safe items now match live, so the
541
+ // subsequent push overlay keeps every remote-only file instead of deleting it
542
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
543
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
544
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
545
+ return { reconciled: true, applied, bothModified, remoteDeleted, changed: changed.length }
546
+ }
547
+
492
548
  // ── Conflict-resolution action button: in-process agent (same channel as
493
549
  // skills-management share-run). The agent operates the shadow repo's git
494
550
  // directly + merges the PR via REST. Only this step needs semantic
@@ -532,6 +588,46 @@ function substituteParams(template, params) {
532
588
  return out
533
589
  }
534
590
 
591
+ // ── AI align action button: semantic merge of files both sides changed.
592
+ // Deterministic reconcile (remote-only pull-back) already ran in the host
593
+ // before this prompt is built; the agent only does the semantic judgement
594
+ // on the reported both-modified files, then triggers a normal sync and
595
+ // falls back to conflict resolution if a PR still can't merge. ──
596
+
597
+ const ALIGN_PROMPT_ZH = [
598
+ '请执行 dsh-sync 的「AI 智能对齐」:把本机与远端都改过的文件做语义合并,然后触发一次同步完成推送。',
599
+ '',
600
+ '## 路径信息',
601
+ '- 影子仓库(git 工作树,只读用于取版本):{{shadowDir}}',
602
+ '- 本机 live 同步根:',
603
+ ' - 技能(dsh):{{skillsDsh}}',
604
+ ' - 技能(agents):{{skillsAgents}}',
605
+ ' - 会话:{{sessions}}',
606
+ ' - 设置文件:{{settingsFile}}',
607
+ ' - 插件清单:{{profiles}}',
608
+ '- 影子路径 → live 路径映射:`skills/dsh/**` → 技能(dsh)根;`skills/agents/**` → 技能(agents)根;`skills/.skill-lock.json` → agents 根下 `.skill-lock.json`;`sessions/**` → 会话根;`settings/settings.yaml` → 设置文件;`plugins/**` → 插件清单根。',
609
+ '- 备份目录:{{backupDir}}(改动前把 live 原文件按影子相对路径复制进去)',
610
+ '- 本机 dsh web 地址:{{apiBase}}(用它触发同步,不需要令牌)',
611
+ '- 访问令牌:{{token}}(仅兜底直接调 GitCode API 时用,严禁回显)',
612
+ '',
613
+ '## 待合并文件(两边都改过,共 {{fileCount}} 个)',
614
+ '{{fileList}}',
615
+ '每个文件的三个版本:本机版直接读 live 路径;远端版 `git -C {{shadowDir}} show FETCH_HEAD:<影子路径>`;共同基线 `git -C {{shadowDir}} show {{lastSynced}}:<影子路径>`(可能不存在)。',
616
+ '',
617
+ '## 规则(硬性)',
618
+ '1. 只允许修改上面清单里的 live 文件;**不得删除**任何 live 文件或远端独有内容;不准碰同步根之外的文件。',
619
+ '2. 动手前把每个 live 原文件备份到 {{backupDir}}(保持影子相对路径的子目录结构)。',
620
+ '3. 文本文件(.md/.json/.yaml/.yml/明文 .jsonl)做三方语义合并,保留两边有效改动。技能/专家目录:两边各自新增的文件取并集(都保留),仅同名文件才合并内容。',
621
+ '4. settings.yaml 逐键保留双方;本机路径/机器相关字段以本机为准;任何 token/apiKey/密钥字段保留两边但**严禁在输出中回显密钥值**。',
622
+ '5. 二进制或压缩文件(.zst/.gz 及 session 日志二进制)不合并,保留本机版,在汇报里列出。',
623
+ '6. 只允许使用 bash 与 HTTP 请求工具;严禁使用 return/deliver/投递/IM 文件类工具;不要 printenv;令牌不得出现在任何输出或提交信息里。',
624
+ '7. 合并完成后触发确定性同步:`curl -s -X POST {{apiBase}}/dsh-sync/api/sync`,等待返回 JSON。',
625
+ '8. 再查状态:`curl -s {{apiBase}}/dsh-sync/api/status`。若 pendingConflict 非空(仍有冲突 PR):在影子仓库 `git fetch https://oauth2:<令牌>@gitcode.com/<owner>/<repo>.git <分支>` → checkout 该分支 → `git merge FETCH_HEAD` → 按上述规则解冲突 → `git add -A && git -c user.name=dsh-sync -c user.email=dsh-sync@local commit --no-edit` → push 回该分支 → 调 GitCode API 合并 PR(头用 `PRIVATE-TOKEN: <令牌>`,不要用 Authorization: Bearer;`PUT /repos/<owner>/<repo>/pulls/<编号>/merge`,body `{"merge_method":"squash"}`)。',
626
+ '9. 全程使用中文。最后汇报:备份了哪些文件、每个文件怎么合并的、同步触发结果、PR 编号与链接(若有)。',
627
+ '',
628
+ '若待合并文件清单为空,跳过合并直接执行第 7 步,并汇报同步结果。',
629
+ ].join('\n')
630
+
535
631
  // apiproxy client: dsh web 的 /api HTTP RPC(web 客户端同款),创建主对话级 session。
536
632
  // 关键区别:apiproxy session.create 建的是 web 主对话级 agent(agentPreset=standard
537
633
  // + dsh-base 全工具,含 bash——实测 tool/call=bash + pwd 跑通);而 agents.create 子 agent
@@ -551,7 +647,7 @@ async function apiproxy(method, payload) {
551
647
  return res.value
552
648
  }
553
649
 
554
- async function runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token }) {
650
+ async function runAgentViaApiproxy({ prompt, dir, job, sessions, logger, token }) {
555
651
  // token 经 prompt 内联({{token}})--apiproxy 主对话级 session 的 bash 是 host-plane
556
652
  // executor,不继承 dsh web 进程的 process.env,故不能像 headless spawn 那样 env 注入
557
653
  try {
@@ -611,18 +707,18 @@ async function runConflictViaApiproxy({ prompt, dir, job, sessions, logger, toke
611
707
  return job
612
708
  }
613
709
 
614
- function createConflictRunJob({ prompt, dir, jobs, logger, sessions, token }) {
615
- const id = 'cf' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
710
+ function createAgentRunJob({ prompt, dir, jobs, logger, sessions, token }) {
711
+ const id = 'ag' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
616
712
  const job = { id, status: 'running', startedAt: new Date().toISOString(), dir, output: '', code: null }
617
713
  jobs.set(id, job)
618
714
  // 走 apiproxy 创建主对话级 session(standard preset + dsh-base 全工具,含 bash),
619
- // 不是 agents.create 子 agent(精简无 bash)。token 注入 env,events 经 ctx.sessions.get 流式读。
715
+ // 不是 agents.create 子 agent(精简无 bash)。token 注入 prompt,events 经 ctx.sessions.get 流式读。
620
716
  if (!sessions || typeof sessions.get !== 'function') {
621
717
  job.status = 'error'
622
718
  job.output = 'sessions 服务不可用(动态 ctx.inject 失败)'
623
719
  return job
624
720
  }
625
- runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token })
721
+ runAgentViaApiproxy({ prompt, dir, job, sessions, logger, token })
626
722
  .catch(e => { job.status = 'error'; job.output = (job.output + '\n' + String(e && e.message)).slice(-CONFLICT_RUN_OUTPUT_CAP) })
627
723
  return job
628
724
  }
@@ -630,7 +726,7 @@ function createConflictRunJob({ prompt, dir, jobs, logger, sessions, token }) {
630
726
  module.exports = {
631
727
  name: 'dsh-sync',
632
728
  inject: ['webServer', 'settings'],
633
- __internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, substituteParams },
729
+ __internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, reconcileRemote, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, ALIGN_PROMPT_ZH, substituteParams },
634
730
 
635
731
  apply(ctx, config = {}) {
636
732
  const dh = dshHome()
@@ -709,6 +805,9 @@ module.exports = {
709
805
  let result = { pushed: false, pulled: false }
710
806
  try {
711
807
  const ctx2 = { repoDir, instanceId: state.instanceId, state, logger: ctx.logger }
808
+ // reconcile first: pull remote-only/untouched changes into live so
809
+ // the full-snapshot push below never deletes another replica's adds
810
+ result.reconcile = await reconcileRemote(eff.gitBinary, eff, ctx2).catch(e => { result.reconcileError = String(e && e.message); return null })
712
811
  result.push = await runPush(eff.gitBinary, eff, ctx2).catch(e => { result.pushError = String(e && e.message); return null })
713
812
  result.pull = await runPull(eff.gitBinary, eff, ctx2).catch(e => { result.pullError = String(e && e.message); return null })
714
813
  state.lastSyncAt = new Date().toISOString()
@@ -720,9 +819,11 @@ module.exports = {
720
819
  return syncRun
721
820
  }
722
821
 
723
- // ── Conflict-resolution jobs (action button → apiproxy 主对话级 session) ──
822
+ // ── Agent-run jobs (action buttons → apiproxy 主对话级 session) ──
823
+ // conflictRunJobs: 冲突 PR 解决;alignRunJobs: AI 智能对齐(语义合并双方改动)
724
824
  const conflictRunJobs = new Map()
725
- // 动态注入 sessions 服务:conflict 走 apiproxy 创建主对话级 session 后,
825
+ const alignRunJobs = new Map()
826
+ // 动态注入 sessions 服务:agent run 走 apiproxy 创建主对话级 session 后,
726
827
  // 用 ctx.sessions.get(sessionId).events 流式读 agent 输出(像 agents.create 事件泵,
727
828
  // 但这个 agent 有 bash)
728
829
  let sessionsSvc = null
@@ -830,7 +931,7 @@ module.exports = {
830
931
  const prompt = substituteParams(CONFLICT_PROMPT_ZH, {
831
932
  repoUrl: eff.repoUrl, shadowDir: repoDir, branch, prNumber, token: eff.token,
832
933
  })
833
- const job = createConflictRunJob({ prompt, dir: repoDir, jobs: conflictRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
934
+ const job = createAgentRunJob({ prompt, dir: repoDir, jobs: conflictRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
834
935
  sendJson(res, 202, { jobId: job.id, status: job.status })
835
936
  return
836
937
  }
@@ -844,6 +945,51 @@ module.exports = {
844
945
  return
845
946
  }
846
947
 
948
+ // POST /dsh-sync/api/align/run → AI 智能对齐:先跑一次确定性同步
949
+ // (远端新增自动回填),再把两边都改过的文件交 agent 语义合并
950
+ if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/align/run')) {
951
+ await stateLoaded
952
+ const eff = syncSettings()
953
+ if (!eff.repoUrl || !eff.token) { sendJson(res, 400, { error: '未配置仓库或令牌' }); return }
954
+ const baseCommit = state.lastSyncedCommit // 双方分叉的共同基线(reconcile 前)
955
+ let syncResult = null
956
+ try { syncResult = await runSync() }
957
+ catch (e) { sendJson(res, 400, { error: '同步预检失败:' + String(e && e.message || e) }); return }
958
+ const rec = syncResult && syncResult.reconcile
959
+ const both = (rec && Array.isArray(rec.bothModified)) ? rec.bothModified : []
960
+ const fileList = both.length
961
+ ? both.map((f, i) => `${i + 1}. ${f.shadowPath}(本机:${displayPath(f.livePath)})`).join('\n')
962
+ : '(无——确定性同步已处理全部差异)'
963
+ const roots = defaultRoots()
964
+ const backupDir = join(syncDir, 'align-backups', new Date().toISOString().replace(/[:.]/g, '-'))
965
+ await fsP.mkdir(backupDir, { recursive: true })
966
+ const prompt = substituteParams(ALIGN_PROMPT_ZH, {
967
+ shadowDir: repoDir,
968
+ skillsDsh: roots.dshSkills, skillsAgents: roots.agentsSkills,
969
+ sessions: roots.sessions, settingsFile: roots.settingsFile, profiles: roots.profiles,
970
+ backupDir, apiBase: APIPROXY_BASE, token: eff.token,
971
+ fileCount: both.length, fileList,
972
+ lastSynced: baseCommit || '(无共同基线,仓库首次同步)',
973
+ })
974
+ const job = createAgentRunJob({ prompt, dir: repoDir, jobs: alignRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
975
+ sendJson(res, 202, {
976
+ jobId: job.id, status: job.status,
977
+ bothModified: both.map(f => f.shadowPath),
978
+ backupDir: displayPath(backupDir),
979
+ reconcile: rec ? { applied: rec.applied, bothModified: rec.bothModified.length, remoteDeleted: rec.remoteDeleted } : null,
980
+ })
981
+ return
982
+ }
983
+
984
+ // GET /dsh-sync/api/align/run?id= → job status/output
985
+ if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/align/run')) {
986
+ const id = query.get('id') || ''
987
+ const job = alignRunJobs.get(id)
988
+ if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
989
+ sendJson(res, 200, { ...job, output: (job.output || '').slice(-32 * 1024) })
990
+ return
991
+ }
992
+
847
993
  sendJson(res, 404, { error: 'not found' })
848
994
  } catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
849
995
  },