@code_tin/dsh-sync 0.1.0 → 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/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  通过 Git 在多个设备之间同步 DeepSeek Harness(DSH)的会话。
4
4
 
5
- - npm:`@code_tin/dsh-sync`;安装:`plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.1.0" }`
6
- - 设置页:**设置 → 会话 Git 同步**,填写 Git 仓库地址与分支,查看本地/镜像状态,直接拉取或推送。
5
+ - npm:`@code_tin/dsh-sync`;安装:`plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.2.0" }`
6
+ - 设置页:**设置 → 会话 Git 同步**,填写 Git 仓库地址与分支,查看本地/镜像状态,直接拉取或推送;可勾选**自动同步**并设置间隔(分钟)。
7
7
  - 命令:`/sync`、`/sync pull`、`/sync push`、`/sync test`。
8
8
  - 数据:本机会话仍在 `~/.dsh/sessions`;插件在 `~/.dsh/dsh-sync/repo` 维护一个专用 Git 镜像仓库,不往会话目录里写 `.git`。
9
9
 
@@ -16,6 +16,7 @@
16
16
 
17
17
  - **push**:先 flush 活跃会话,把本地 `session.*.jsonl.zstd` 复制进镜像,提交并推送。推送被拒绝(其他设备刚推过)时会自动 fetch 重试一次;同一会话冲突时本地文件优先。
18
18
  - **pull**:把镜像快进到远程分支,只复制本地**不存在**的会话文件。本地已有的会话一律保留,并计入“跳过”数量。
19
+ - **自动同步**:勾选后,宿主进程每隔设定的分钟数执行一次 push(首次在保存后一个间隔触发),不需要手动点按钮。开关、间隔改动保存后立即重新计时;关闭后不再调度。
19
20
  - 只同步会话日志文件;`session.lock`、`.DS_Store` 等不会进入仓库。
20
21
 
21
22
  ## 使用
@@ -23,8 +24,9 @@
23
24
  1. 在设置页填写仓库地址,例如 `https://github.com/me/dsh-sessions.git` 或 `git@github.com:me/dsh-sessions.git`,保存。
24
25
  2. 在设备 A 执行 `/sync push`(或点“推送到远程”)。
25
26
  3. 在设备 B 保存同一地址后执行 `/sync pull`,刷新侧边栏(或重新打开窗口)即可看到新会话。
27
+ 4. 需要持续同步时勾选“自动同步”,填入间隔分钟数(1–1440,默认 10),保存。状态区会显示下次自动推送时间。
26
28
 
27
- `/sync` 显示状态;`/sync test` 测试连接。Git 认证沿用本机的 SSH agent / credential helper,插件不会保存凭据。若 HTTPS 需要账号密码,请先配置好 credential helper,否则命令会快速失败而不是弹出交互提示。
29
+ `/sync` 显示状态(含自动同步开关、下次时间和上次自动推送结果);`/sync test` 测试连接。Git 认证沿用本机的 SSH agent / credential helper,插件不会保存凭据。若 HTTPS 需要账号密码,请先配置好 credential helper,否则命令会快速失败而不是弹出交互提示。
28
30
 
29
31
  ## 安装(开发目录)
30
32
 
@@ -41,6 +43,8 @@ plugin_manager install_bundle { "target": "/path/to/dsh-sync" }
41
43
  - 同一个会话如果在两台设备上继续写入,pull 不会合并日志:保留本地版本并跳过该文件。需要时请在镜像仓库里手动取出远端版本。
42
44
  - 首次 pull 之前远程必须至少有一次 push(远程分支不存在时 pull 会明确报错)。
43
45
  - 镜像仓库会保留历史上推送过的会话;本机删除会话不会删除镜像中的副本。
46
+ - 自动同步只做 push,不自动 pull;需要把其他设备的会话取回时仍要手动 pull。
47
+ - 自动同步依赖 DSH 宿主进程持续运行;宿主退出期间不会推送,重启后从新间隔重新计时;系统休眠唤醒后会在计时到期时补触发一次。失败只记录到状态与日志,不会重试。
44
48
  - Git 地址保存在 `~/.dsh/dsh-sync/settings.json`(权限 0600)。不要把带 token 的 URL 提交到任何地方。
45
49
 
46
50
  ## 分发给其他设备
@@ -51,13 +55,13 @@ plugin_manager install_bundle { "target": "/path/to/dsh-sync" }
51
55
 
52
56
  ```bash
53
57
  npm pack
54
- # 生成 code_tin-dsh-sync-0.1.0.tgz
58
+ # 生成 code_tin-dsh-sync-0.2.0.tgz
55
59
  ```
56
60
 
57
61
  把 tgz 拷到目标机器,在 DSH 里安装:
58
62
 
59
63
  ```
60
- plugin_manager install_bundle { "target": "/绝对路径/code_tin-dsh-sync-0.1.0.tgz" }
64
+ plugin_manager install_bundle { "target": "/绝对路径/code_tin-dsh-sync-0.2.0.tgz" }
61
65
  ```
62
66
 
63
67
  Plugin Manager 支持目录、tarball、git 地址和 npm 包名这几类安装 spec。
@@ -74,7 +78,7 @@ npm publish --access public
74
78
  发布后其他机器直接安装:
75
79
 
76
80
  ```
77
- plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.1.0" }
81
+ plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.2.0" }
78
82
  ```
79
83
 
80
84
  注意:发布到 npm 只解决“可安装”,不会自动让插件出现在 DSH 插件市场的 Discover 列表;市场收录通常还需要公开仓库和索引源。
package/client.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * Browser half of `dsh-sync`.
3
3
  *
4
4
  * Adds one page under Settings → 会话 Git 同步 (Settings → Session Git Sync).
5
- * The page writes the repository URL and branch, and triggers pull/push through
6
- * the host Remote service registered by `index.js`. All session bytes and git
7
- * work stay on the host; this module only renders state and calls endpoints.
5
+ * The page writes the repository URL, branch, and auto-sync schedule, and
6
+ * triggers pull/push through the host Remote service registered by `index.js`.
7
+ * All session bytes and git work stay on the host; this module only renders
8
+ * state and calls endpoints.
8
9
  */
9
10
  window.__ModuleLoader__.load({
10
11
  id: '@code_tin/dsh-sync',
@@ -17,18 +18,23 @@ window.__ModuleLoader__.load({
17
18
  const ZH = {
18
19
  nav: '会话 Git 同步',
19
20
  title: '会话 Git 同步',
20
- subtitle: '把本机的 DSH 会话镜像到一个 Git 仓库,在多个设备之间共享。拉取只新增本地不存在的会话,不会覆盖本地已有会话。',
21
+ subtitle: '把本机的 DSH 会话镜像到一个 Git 仓库,在多个设备之间共享。拉取只新增本地不存在的会话,不会覆盖本地已有会话;也可以开启定时自动推送。',
21
22
  remoteLabel: 'Git 仓库地址',
22
23
  remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
23
24
  branchLabel: '分支',
24
25
  branchPlaceholder: 'main',
26
+ autoLabel: '自动同步',
27
+ autoHint: '开启后每隔下面的时间自动把本地会话推送到 Git(等同 /sync push),DSH 宿主进程需要保持运行。',
28
+ intervalLabel: '自动同步间隔(分钟)',
29
+ intervalUnit: '分钟',
30
+ intervalInvalid: '自动同步间隔需要是 1 到 1440 之间的整数分钟。',
25
31
  save: '保存',
26
32
  test: '测试连接',
27
33
  pull: '从远程拉取',
28
34
  push: '推送到远程',
29
35
  refresh: '刷新状态',
30
36
  unsaved: '有未保存的修改;拉取或推送前会自动保存。',
31
- saved: '已保存 Git 地址。',
37
+ saved: '已保存设置。',
32
38
  statusTitle: '同步状态',
33
39
  commandsTitle: '命令',
34
40
  cmdStatus: '查看同步状态',
@@ -39,8 +45,14 @@ window.__ModuleLoader__.load({
39
45
  branchStatus: '分支',
40
46
  localStatus: '本地会话 / 文件',
41
47
  mirrorStatus: '镜像仓库',
48
+ autoStatus: '自动同步',
49
+ autoOff: '已关闭',
50
+ autoOn: '每 {minutes} 分钟',
51
+ autoNext: '每 {minutes} 分钟,下次 {time}',
52
+ autoNoRemote: '已开启,尚未配置远程仓库',
42
53
  lastPush: '上次推送',
43
54
  lastPull: '上次拉取',
55
+ lastAutoPush: '上次自动推送',
44
56
  dataDir: '本地会话目录',
45
57
  repoDir: '镜像仓库目录',
46
58
  notConfigured: '未配置',
@@ -61,18 +73,23 @@ window.__ModuleLoader__.load({
61
73
  const EN = {
62
74
  nav: 'Session Git Sync',
63
75
  title: 'Session Git Sync',
64
- subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session.',
76
+ subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session. Optionally push on a timer.',
65
77
  remoteLabel: 'Git repository URL',
66
78
  remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
67
79
  branchLabel: 'Branch',
68
80
  branchPlaceholder: 'main',
81
+ autoLabel: 'Auto sync',
82
+ autoHint: 'When enabled, local sessions are pushed to git every interval (same as /sync push). The DSH host process must stay running.',
83
+ intervalLabel: 'Auto sync interval (minutes)',
84
+ intervalUnit: 'min',
85
+ intervalInvalid: 'The auto sync interval must be a whole number between 1 and 1440 minutes.',
69
86
  save: 'Save',
70
87
  test: 'Test connection',
71
88
  pull: 'Pull from remote',
72
89
  push: 'Push to remote',
73
90
  refresh: 'Refresh status',
74
91
  unsaved: 'Unsaved changes; pull or push saves them first.',
75
- saved: 'Git URL saved.',
92
+ saved: 'Settings saved.',
76
93
  statusTitle: 'Sync status',
77
94
  commandsTitle: 'Commands',
78
95
  cmdStatus: 'show sync status',
@@ -83,8 +100,14 @@ window.__ModuleLoader__.load({
83
100
  branchStatus: 'Branch',
84
101
  localStatus: 'Local sessions / files',
85
102
  mirrorStatus: 'Mirror',
103
+ autoStatus: 'Auto sync',
104
+ autoOff: 'Off',
105
+ autoOn: 'Every {minutes} min',
106
+ autoNext: 'Every {minutes} min, next {time}',
107
+ autoNoRemote: 'On, but no remote repository is configured',
86
108
  lastPush: 'Last push',
87
109
  lastPull: 'Last pull',
110
+ lastAutoPush: 'Last auto push',
88
111
  dataDir: 'Local session directory',
89
112
  repoDir: 'Mirror directory',
90
113
  notConfigured: 'Not configured',
@@ -112,6 +135,12 @@ window.__ModuleLoader__.load({
112
135
  .dshsync-row { display: flex; gap: 10px; }
113
136
  .dshsync-grow { flex: 1 1 auto; min-width: 0; }
114
137
  .dshsync-branch { width: 170px; flex: 0 0 auto; }
138
+ .dshsync-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-primary); cursor: pointer; }
139
+ .dshsync-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--dsw-alias-brand-primary); }
140
+ .dshsync-field-hint { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
141
+ .dshsync-interval-row { display: flex; align-items: center; gap: 8px; }
142
+ .dshsync-interval-input { width: 120px; }
143
+ .dshsync-unit { font-size: 12px; color: var(--dsw-alias-label-secondary); }
115
144
  .dshsync-label { font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
116
145
  .dshsync-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
117
146
  .dshsync-input::placeholder { color: var(--dsw-alias-state-idle-primary); }
@@ -184,7 +213,12 @@ window.__ModuleLoader__.load({
184
213
  }
185
214
  return {
186
215
  status: () => call('status'),
187
- save: (remote, branch) => call('save', { remote, branch }),
216
+ save: (remote, branch, autoPush, autoPushIntervalMinutes) => call('save', {
217
+ remote,
218
+ branch,
219
+ autoPush,
220
+ autoPushIntervalMinutes,
221
+ }),
188
222
  test: (remote) => call('test', { remote }),
189
223
  pull: () => call('pull'),
190
224
  push: () => call('push'),
@@ -196,6 +230,8 @@ window.__ModuleLoader__.load({
196
230
  const [snapshot, setSnapshot] = React.useState(null)
197
231
  const [remote, setRemote] = React.useState('')
198
232
  const [branch, setBranch] = React.useState('main')
233
+ const [autoPush, setAutoPush] = React.useState(false)
234
+ const [intervalText, setIntervalText] = React.useState('10')
199
235
  const [dirty, setDirty] = React.useState(false)
200
236
  const [busy, setBusy] = React.useState('')
201
237
  const [notice, setNotice] = React.useState(null)
@@ -212,6 +248,8 @@ window.__ModuleLoader__.load({
212
248
  setSnapshot(value)
213
249
  setRemote(value.remote || '')
214
250
  setBranch(value.branch || 'main')
251
+ setAutoPush(value.autoPush === true)
252
+ setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
215
253
  })
216
254
  .catch((error) => {
217
255
  if (alive) setNotice({ kind: 'error', text: `${t('loadFailed')}: ${errorText(error)}` })
@@ -241,19 +279,38 @@ window.__ModuleLoader__.load({
241
279
  }
242
280
  }
243
281
 
282
+ const parseInterval = () => {
283
+ const value = Number(intervalText)
284
+ return Number.isInteger(value) && value >= 1 && value <= 1440 ? value : null
285
+ }
286
+
287
+ const applySettings = (settings) => {
288
+ if (!settings) return
289
+ if (typeof settings.remote === 'string') setRemote(settings.remote)
290
+ if (typeof settings.branch === 'string') setBranch(settings.branch)
291
+ if (typeof settings.autoPush === 'boolean') setAutoPush(settings.autoPush)
292
+ if (settings.autoPushIntervalMinutes !== undefined) {
293
+ setIntervalText(String(settings.autoPushIntervalMinutes))
294
+ }
295
+ }
296
+
244
297
  const saveFirst = async () => {
245
298
  if (!dirty) return true
299
+ const interval = parseInterval()
300
+ if (interval === null) {
301
+ setNotice({ kind: 'error', text: t('intervalInvalid') })
302
+ return false
303
+ }
246
304
  setBusy('save')
247
305
  try {
248
- const saved = await rpc.save(remote.trim(), branch.trim())
306
+ const saved = await rpc.save(remote.trim(), branch.trim(), autoPush, interval)
249
307
  if (saved && saved.status) setSnapshot(saved.status)
250
308
  if (saved && saved.ok === false) {
251
309
  setNotice({ kind: 'error', text: saved.message || t('failed') })
252
310
  return false
253
311
  }
254
312
  setDirty(false)
255
- setRemote(saved && saved.settings ? saved.settings.remote : remote.trim())
256
- setBranch(saved && saved.settings ? saved.settings.branch : branch.trim())
313
+ applySettings(saved && saved.settings)
257
314
  return true
258
315
  } catch (error) {
259
316
  setNotice({ kind: 'error', text: errorText(error) })
@@ -264,13 +321,15 @@ window.__ModuleLoader__.load({
264
321
  }
265
322
 
266
323
  const onSave = async () => {
267
- const result = await perform('save', () => rpc.save(remote.trim(), branch.trim()))
324
+ const interval = parseInterval()
325
+ if (interval === null) {
326
+ setNotice({ kind: 'error', text: t('intervalInvalid') })
327
+ return
328
+ }
329
+ const result = await perform('save', () => rpc.save(remote.trim(), branch.trim(), autoPush, interval))
268
330
  if (!result || result.ok === false) return
269
331
  setDirty(false)
270
- if (result.settings) {
271
- setRemote(result.settings.remote)
272
- setBranch(result.settings.branch)
273
- }
332
+ applySettings(result.settings)
274
333
  setNotice({ kind: 'ok', text: t('saved') })
275
334
  }
276
335
 
@@ -321,6 +380,8 @@ window.__ModuleLoader__.load({
321
380
  if (!dirty) {
322
381
  setRemote(value.remote || '')
323
382
  setBranch(value.branch || 'main')
383
+ setAutoPush(value.autoPush === true)
384
+ setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
324
385
  }
325
386
  setNotice({ kind: 'info', text: t('refreshed') })
326
387
  } catch (error) {
@@ -334,6 +395,14 @@ window.__ModuleLoader__.load({
334
395
  const state = snapshot && snapshot.state ? snapshot.state : {}
335
396
  const local = snapshot && snapshot.local ? snapshot.local : { sessions: 0, files: 0 }
336
397
  const mirror = snapshot && snapshot.mirror ? snapshot.mirror : { initialized: false, sessions: 0, files: 0 }
398
+ const autoText = (() => {
399
+ if (!snapshot || snapshot.autoPush !== true) return t('autoOff')
400
+ if (!snapshot.remote) return t('autoNoRemote')
401
+ const minutes = snapshot.autoPushIntervalMinutes ?? 10
402
+ return snapshot.autoPushNextAt
403
+ ? format(t('autoNext'), { minutes, time: timeText(snapshot.autoPushNextAt) })
404
+ : format(t('autoOn'), { minutes })
405
+ })()
337
406
 
338
407
  const row = (key, label, value, mono) => [
339
408
  h('dt', { className: 'dshsync-k', key: `${key}-k` }, label),
@@ -385,6 +454,42 @@ window.__ModuleLoader__.load({
385
454
  }),
386
455
  ),
387
456
  ),
457
+ h('div', { className: 'dshsync-field' },
458
+ h('label', { className: 'dshsync-check', htmlFor: 'dshsync-auto' },
459
+ h('input', {
460
+ id: 'dshsync-auto',
461
+ type: 'checkbox',
462
+ checked: autoPush,
463
+ disabled: busy === 'save',
464
+ onChange: (event) => {
465
+ setAutoPush(event.target.checked)
466
+ setDirty(true)
467
+ },
468
+ }),
469
+ h('span', null, t('autoLabel')),
470
+ ),
471
+ h('p', { className: 'dshsync-field-hint' }, t('autoHint')),
472
+ ),
473
+ h('div', { className: 'dshsync-field dshsync-interval' },
474
+ h('label', { className: 'dshsync-label', htmlFor: 'dshsync-interval' }, t('intervalLabel')),
475
+ h('div', { className: 'dshsync-interval-row' },
476
+ h('input', {
477
+ id: 'dshsync-interval',
478
+ className: 'dshsync-input dshsync-interval-input',
479
+ type: 'number',
480
+ min: 1,
481
+ max: 1440,
482
+ step: 1,
483
+ value: intervalText,
484
+ disabled: busy === 'save',
485
+ onChange: (event) => {
486
+ setIntervalText(event.target.value)
487
+ setDirty(true)
488
+ },
489
+ }),
490
+ h('span', { className: 'dshsync-unit' }, t('intervalUnit')),
491
+ ),
492
+ ),
388
493
  h('div', { className: 'dshsync-actions' },
389
494
  h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-primary', onClick: onSave, disabled }, t('save')),
390
495
  h('button', { type: 'button', className: 'dshsync-btn', onClick: onTest, disabled }, t('test')),
@@ -406,6 +511,7 @@ window.__ModuleLoader__.load({
406
511
  : h('dl', { className: 'dshsync-grid' },
407
512
  row('remote', t('remoteStatus'), snapshot.remote || t('notConfigured')),
408
513
  row('branch', t('branchStatus'), snapshot.branch),
514
+ row('auto', t('autoStatus'), autoText),
409
515
  row('local', t('localStatus'), `${local.sessions} / ${local.files}`),
410
516
  row('mirror', t('mirrorStatus'), mirror.initialized
411
517
  ? `${mirror.sessions} / ${mirror.files}`
@@ -413,6 +519,9 @@ window.__ModuleLoader__.load({
413
519
  row('push', t('lastPush'), state.lastPushAt
414
520
  ? `${timeText(state.lastPushAt)}${state.lastPushOk === false ? ' ✕' : ''}`
415
521
  : t('never')),
522
+ row('autoPush', t('lastAutoPush'), state.lastAutoPushAt
523
+ ? `${timeText(state.lastAutoPushAt)}${state.lastAutoPushOk === false ? ' ✕' : ''}`
524
+ : t('never')),
416
525
  row('pull', t('lastPull'), state.lastPullAt
417
526
  ? `${timeText(state.lastPullAt)}${state.lastPullOk === false ? ' ✕' : ''}`
418
527
  : t('never')),
package/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
14
  import { SessionGitSync, validateBranch } from './lib/sync.js'
15
+ import { AutoPushScheduler } from './lib/auto.js'
15
16
  import { registerRemoteService } from './lib/remote.js'
16
17
 
17
18
  const SERVICE_KEY = 'dshSync'
@@ -37,9 +38,24 @@ function formatStatus(status) {
37
38
  ? `${status.mirror.sessions} 个会话 / ${status.mirror.files} 个文件`
38
39
  : '尚未初始化(首次 /sync push 时创建)'}`,
39
40
  ]
41
+ if (status.autoPush) {
42
+ if (!status.remote) {
43
+ lines.push(`自动同步: 已开启(每 ${status.autoPushIntervalMinutes} 分钟),但尚未配置远程仓库`)
44
+ } else {
45
+ const next = status.autoPushNextAt
46
+ ? `,下次 ${formatTime(new Date(status.autoPushNextAt).toISOString())}`
47
+ : ''
48
+ lines.push(`自动同步: 每 ${status.autoPushIntervalMinutes} 分钟推送一次${next}`)
49
+ }
50
+ } else {
51
+ lines.push('自动同步: 已关闭(在设置页开启)')
52
+ }
40
53
  if (status.state.lastPushAt) {
41
54
  lines.push(`上次推送: ${formatTime(status.state.lastPushAt)} ${status.state.lastPushOk === false ? '失败' : '成功'}`)
42
55
  }
56
+ if (status.state.lastAutoPushAt) {
57
+ lines.push(`上次自动推送: ${formatTime(status.state.lastAutoPushAt)} ${status.state.lastAutoPushOk === false ? '失败' : '成功'}`)
58
+ }
43
59
  if (status.state.lastPullAt) {
44
60
  lines.push(`上次拉取: ${formatTime(status.state.lastPullAt)} ${status.state.lastPullOk === false ? '失败' : '成功'}`)
45
61
  }
@@ -63,6 +79,19 @@ export function apply(ctx) {
63
79
  logger: ctx.logger,
64
80
  flush: () => ctx.get('sessionPersistence')?.flush(),
65
81
  })
82
+ const scheduler = new AutoPushScheduler({ sync, logger: ctx.logger })
83
+
84
+ /** Status plus the live timer state the page and command both display. */
85
+ const readStatus = async () => ({
86
+ ...(await sync.status()),
87
+ autoPushNextAt: scheduler.nextAt,
88
+ autoPushRunning: scheduler.running,
89
+ })
90
+
91
+ ctx.effect(() => {
92
+ scheduler.start()
93
+ return () => scheduler.stop()
94
+ }, 'dsh-sync: auto push timer')
66
95
 
67
96
  ctx.effect(() => ctx.commands.register({
68
97
  name: 'sync',
@@ -72,7 +101,7 @@ export function apply(ctx) {
72
101
  const argument = String(invocation.rawInput ?? '').trim().toLowerCase()
73
102
  try {
74
103
  if (argument === '' || argument === 'status') {
75
- return success(formatStatus(await sync.status()))
104
+ return success(formatStatus(await readStatus()))
76
105
  }
77
106
  if (argument === 'help') {
78
107
  return success([
@@ -115,7 +144,7 @@ export function apply(ctx) {
115
144
  const withStatus = async (result) => {
116
145
  let status
117
146
  try {
118
- status = await sync.status()
147
+ status = await readStatus()
119
148
  } catch {
120
149
  status = undefined
121
150
  }
@@ -130,14 +159,15 @@ export function apply(ctx) {
130
159
  {
131
160
  method: 'status',
132
161
  implementation: function status() {
133
- return sync.status()
162
+ return readStatus()
134
163
  },
135
164
  },
136
165
  {
137
166
  method: 'save',
138
- implementation: async function save(remote, branch) {
167
+ implementation: async function save(remote, branch, autoPush, autoPushIntervalMinutes) {
139
168
  const normalizedBranch = validateBranch(branch)
140
- const result = await sync.save(remote, normalizedBranch)
169
+ const result = await sync.save(remote, normalizedBranch, autoPush, autoPushIntervalMinutes)
170
+ await scheduler.refresh()
141
171
  return withStatus(result)
142
172
  },
143
173
  },
package/lib/auto.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Periodic auto-push for `dsh-sync`.
3
+ *
4
+ * The host owns one scheduler; it re-reads settings before every run so the
5
+ * checkbox and interval in the Settings page take effect without a restart.
6
+ * Each run is a normal `push`, so it shares the engine's queue and state
7
+ * recording with manual pushes.
8
+ */
9
+ import { DEFAULT_AUTO_PUSH_INTERVAL_MINUTES } from './sync.js'
10
+
11
+ export class AutoPushScheduler {
12
+ constructor({ sync, logger, setTimer, clearTimer } = {}) {
13
+ if (!sync) throw new Error('AutoPushScheduler needs a sync engine.')
14
+ this.sync = sync
15
+ this.logger = logger
16
+ this.setTimer = setTimer ?? ((fn, ms) => setTimeout(fn, ms))
17
+ this.clearTimer = clearTimer ?? ((id) => clearTimeout(id))
18
+ this.stopped = true
19
+ this.timer = undefined
20
+ this.nextAt = null
21
+ this.running = false
22
+ }
23
+
24
+ /** Begin watching settings; the first run happens one interval from now. */
25
+ start() {
26
+ if (!this.stopped) return
27
+ this.stopped = false
28
+ void this.arm()
29
+ }
30
+
31
+ stop() {
32
+ this.stopped = true
33
+ this.cancel()
34
+ this.nextAt = null
35
+ }
36
+
37
+ /** Re-arm after settings changed (enable/disable/interval). */
38
+ refresh() {
39
+ if (this.stopped) return Promise.resolve()
40
+ return this.arm()
41
+ }
42
+
43
+ cancel() {
44
+ if (this.timer !== undefined) {
45
+ this.clearTimer(this.timer)
46
+ this.timer = undefined
47
+ }
48
+ }
49
+
50
+ async arm() {
51
+ this.cancel()
52
+ this.nextAt = null
53
+ if (this.stopped) return
54
+ let settings
55
+ try {
56
+ settings = await this.sync.readSettings()
57
+ } catch (error) {
58
+ this.logger?.warn?.(`dsh-sync: cannot read settings for auto-sync: ${describeError(error)}`)
59
+ return
60
+ }
61
+ if (this.stopped) return
62
+ if (!settings.autoPush || settings.remote === '') return
63
+ const minutes = settings.autoPushIntervalMinutes || DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
64
+ const delay = minutes * 60_000
65
+ this.nextAt = Date.now() + delay
66
+ this.timer = this.setTimer(() => {
67
+ void this.tick()
68
+ }, delay)
69
+ // A pending auto-push must never keep the host process alive.
70
+ this.timer?.unref?.()
71
+ }
72
+
73
+ async tick() {
74
+ this.timer = undefined
75
+ this.nextAt = null
76
+ if (this.stopped) return
77
+ if (this.running) {
78
+ void this.arm()
79
+ return
80
+ }
81
+ let settings
82
+ try {
83
+ settings = await this.sync.readSettings()
84
+ } catch (error) {
85
+ this.logger?.warn?.(`dsh-sync: cannot read settings for auto-sync: ${describeError(error)}`)
86
+ void this.arm()
87
+ return
88
+ }
89
+ if (!settings.autoPush || settings.remote === '') return
90
+
91
+ this.running = true
92
+ let outcome = { ok: false }
93
+ try {
94
+ const result = await this.sync.push()
95
+ outcome = result
96
+ if (result?.ok === false) {
97
+ this.logger?.warn?.(`dsh-sync: auto push skipped: ${result.message ?? 'not configured'}`)
98
+ }
99
+ } catch (error) {
100
+ this.logger?.warn?.(`dsh-sync: auto push failed: ${describeError(error)}`)
101
+ } finally {
102
+ this.running = false
103
+ if (!this.stopped) void this.arm()
104
+ }
105
+ try {
106
+ await this.sync.recordAutoPush(outcome)
107
+ } catch (error) {
108
+ this.logger?.warn?.(`dsh-sync: cannot record auto push: ${describeError(error)}`)
109
+ }
110
+ }
111
+ }
112
+
113
+ function describeError(error) {
114
+ return error instanceof Error ? error.message : String(error)
115
+ }
package/lib/sync.js CHANGED
@@ -24,6 +24,11 @@ const TEST_TIMEOUT_MS = 45_000
24
24
  const MAX_BUFFER = 16 * 1024 * 1024
25
25
  const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/
26
26
 
27
+ /** Auto-sync defaults and bounds, in minutes. */
28
+ export const DEFAULT_AUTO_PUSH_INTERVAL_MINUTES = 10
29
+ export const MIN_AUTO_PUSH_INTERVAL_MINUTES = 1
30
+ export const MAX_AUTO_PUSH_INTERVAL_MINUTES = 1440
31
+
27
32
  /** A failed operation carrying the raw git result when one exists. */
28
33
  export class SyncFailure extends Error {
29
34
  constructor(message, details) {
@@ -125,6 +130,37 @@ export function validateBranch(branch) {
125
130
  return value
126
131
  }
127
132
 
133
+ /**
134
+ * Validate the auto-sync interval (whole minutes).
135
+ *
136
+ * @param {unknown} value
137
+ * @returns {number}
138
+ */
139
+ export function normalizeAutoPushInterval(value) {
140
+ if (value === undefined || value === null || value === '') {
141
+ return DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
142
+ }
143
+ const number = Number(value)
144
+ if (!Number.isInteger(number)
145
+ || number < MIN_AUTO_PUSH_INTERVAL_MINUTES
146
+ || number > MAX_AUTO_PUSH_INTERVAL_MINUTES) {
147
+ throw new SyncFailure(
148
+ `Auto-sync interval must be a whole number between ${MIN_AUTO_PUSH_INTERVAL_MINUTES} and ${MAX_AUTO_PUSH_INTERVAL_MINUTES} minutes.`,
149
+ )
150
+ }
151
+ return number
152
+ }
153
+
154
+ /** Interval for an already persisted settings file; bad values fall back. */
155
+ function storedAutoPushInterval(value) {
156
+ const number = Number(value)
157
+ return Number.isInteger(number)
158
+ && number >= MIN_AUTO_PUSH_INTERVAL_MINUTES
159
+ && number <= MAX_AUTO_PUSH_INTERVAL_MINUTES
160
+ ? number
161
+ : DEFAULT_AUTO_PUSH_INTERVAL_MINUTES
162
+ }
163
+
128
164
  async function writeJsonAtomic(file, value) {
129
165
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
130
166
  await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
@@ -180,6 +216,8 @@ export class SessionGitSync {
180
216
  return {
181
217
  remote: typeof stored?.remote === 'string' ? stored.remote : '',
182
218
  branch: typeof stored?.branch === 'string' && stored.branch ? stored.branch : 'main',
219
+ autoPush: stored?.autoPush === true,
220
+ autoPushIntervalMinutes: storedAutoPushInterval(stored?.autoPushIntervalMinutes),
183
221
  }
184
222
  }
185
223
 
@@ -192,6 +230,8 @@ export class SessionGitSync {
192
230
  lastPullAt: typeof stored?.lastPullAt === 'string' ? stored.lastPullAt : null,
193
231
  lastPullOk: typeof stored?.lastPullOk === 'boolean' ? stored.lastPullOk : null,
194
232
  lastPullSummary: typeof stored?.lastPullSummary === 'string' ? stored.lastPullSummary : null,
233
+ lastAutoPushAt: typeof stored?.lastAutoPushAt === 'string' ? stored.lastAutoPushAt : null,
234
+ lastAutoPushOk: typeof stored?.lastAutoPushOk === 'boolean' ? stored.lastAutoPushOk : null,
195
235
  lastError: typeof stored?.lastError === 'string' ? stored.lastError : null,
196
236
  }
197
237
  }
@@ -202,6 +242,14 @@ export class SessionGitSync {
202
242
  await writeJsonAtomic(this.statePath, next)
203
243
  }
204
244
 
245
+ /** Mark the outcome of one scheduled auto-sync run. */
246
+ async recordAutoPush(result) {
247
+ await this.recordState({
248
+ lastAutoPushAt: new Date().toISOString(),
249
+ lastAutoPushOk: result?.ok !== false,
250
+ })
251
+ }
252
+
205
253
  async gitVersion() {
206
254
  if (this.gitVersionPromise === undefined) {
207
255
  this.gitVersionPromise = (async () => {
@@ -274,6 +322,8 @@ export class SessionGitSync {
274
322
  configured: settings.remote !== '',
275
323
  remote: settings.remote,
276
324
  branch: settings.branch,
325
+ autoPush: settings.autoPush,
326
+ autoPushIntervalMinutes: settings.autoPushIntervalMinutes,
277
327
  repoDir: this.repoDir,
278
328
  sessionsRoot: this.sessionsRoot,
279
329
  local: summarize(localFiles),
@@ -283,19 +333,33 @@ export class SessionGitSync {
283
333
  }
284
334
  }
285
335
 
286
- async save(remote, branch) {
336
+ async save(remote, branch, autoPush, autoPushIntervalMinutes) {
287
337
  const normalizedRemote = typeof remote === 'string' ? remote.trim() : ''
288
338
  const normalizedBranch = validateBranch(branch)
339
+ // Omitted auto-sync fields keep their stored values, so an older client
340
+ // that only sends remote/branch cannot silently disable a running schedule.
341
+ const current = await this.readSettings()
342
+ const normalizedAutoPush = autoPush === undefined ? current.autoPush : autoPush === true
343
+ const normalizedInterval = autoPushIntervalMinutes === undefined
344
+ ? current.autoPushIntervalMinutes
345
+ : normalizeAutoPushInterval(autoPushIntervalMinutes)
289
346
  if (normalizedRemote !== '') validateRemote(normalizedRemote)
290
347
  return this.enqueue(async () => {
291
348
  await fs.mkdir(this.dataDir, { recursive: true })
292
349
  await writeJsonAtomic(this.settingsPath, {
293
350
  remote: normalizedRemote,
294
351
  branch: normalizedBranch,
352
+ autoPush: normalizedAutoPush,
353
+ autoPushIntervalMinutes: normalizedInterval,
295
354
  })
296
355
  return {
297
356
  ok: true,
298
- settings: { remote: normalizedRemote, branch: normalizedBranch },
357
+ settings: {
358
+ remote: normalizedRemote,
359
+ branch: normalizedBranch,
360
+ autoPush: normalizedAutoPush,
361
+ autoPushIntervalMinutes: normalizedInterval,
362
+ },
299
363
  }
300
364
  })
301
365
  }
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Session Git Sync",
4
- "description": "Mirror DSH sessions into a git repository and share them across devices: /sync status, /sync pull and /sync push, plus a Settings page for the repository URL."
4
+ "description": "Mirror DSH sessions into a git repository and share them across devices: /sync status, /sync pull and /sync push, plus a Settings page for the repository URL and timed auto-push."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "会话 Git 同步",
4
- "description": "把 DSH 会话镜像到 Git 仓库,在多个设备之间共享:/sync、/sync pull、/sync push,并在设置页面中配置仓库地址。"
4
+ "description": "把 DSH 会话镜像到 Git 仓库,在多个设备之间共享:/sync、/sync pull、/sync push,并在设置页面中配置仓库地址与定时自动推送。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@code_tin/dsh-sync",
3
- "version": "0.1.0",
4
- "description": "Sync DeepSeek Harness sessions across devices through a git repository, with a Settings page and /sync, /sync pull, /sync push commands.",
3
+ "version": "0.2.1",
4
+ "description": "Sync DeepSeek Harness sessions across devices through a git repository, with optional timed auto-push, a Settings page, and /sync commands.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/icon.svg DELETED
@@ -1,12 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session Git Sync">
2
- <rect width="64" height="64" rx="14" fill="#1f6feb"/>
3
- <g fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
- <path d="M21 16v23a6 6 0 0 0 6 6h14"/>
5
- <path d="M43 16v8"/>
6
- </g>
7
- <circle cx="21" cy="13" r="5" fill="#ffffff"/>
8
- <circle cx="43" cy="13" r="5" fill="#ffffff"/>
9
- <circle cx="15" cy="47" r="5" fill="#ffffff"/>
10
- <path d="M44 36a9 9 0 0 1 6 15" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round"/>
11
- <path d="M52 44v7h-7" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
12
- </svg>