dsh-recall-plugin 1.2.2 → 1.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,76 @@
1
+ # 更新日志
2
+
3
+ 本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
+
5
+ ## [1.3.0] - 2026-08-17
6
+
7
+ ### 新增
8
+
9
+ - 设置页「撤回设置」标签(设置 → 插件):可视化编辑快照排除项——输入路径或模式回车即加、常用模式一键追加(`dist/`、`*.log`、`.env` 等)、放弃修改/保存与未保存状态提示,保存后下一次快照/预览/回退立即生效,无需重启。
10
+ - Host 端 `exclude-get` / `exclude-set` HTTP 端点:枚举并读写全部 exclude.txt(home 存储全局共享一份,降级工作区各自独立、分卡片展示);写入走 base64 分块(win32)/ stdin(POSIX),任意长度配置不受命令行上限约束;写入路径经服务端白名单校验(仅接受枚举结果中的路径)。
11
+ - 冷启动兜底:会话注册表未载入时按磁盘 home 容器目录枚举 exclude.txt(`resolveHomeContainer`),设置页不再误报「尚未创建快照存储」。
12
+
13
+ ### 兼容性
14
+
15
+ - 全部新增 shell 命令在 Windows PowerShell 5.1 与 WSL2 Ubuntu(bash)实测通过,覆盖中文/空格路径、CRLF、空文件、缺失文件等边界。
16
+
17
+ ## [1.2.2] - 2026-08-15
18
+
19
+ ### 修复
20
+
21
+ - 撤回出的新会话不再向标题追加递增数字:fork 不传 `increaseTitle`,原样继承原标题。
22
+
23
+ ### 文档
24
+
25
+ - 新增英文 README(README.en.md,与中文版互链)与 AGENTS.md 项目速览。
26
+
27
+ ## [1.2.1] - 2026-08-15
28
+
29
+ ### 修复
30
+
31
+ - 修正 package.json 仓库地址(仓库改名后同步);README 安装地址同步。
32
+
33
+ ## [1.2.0] - 2026-08-15
34
+
35
+ ### 新增
36
+
37
+ - Linux/macOS(bash)平台支持:与 Windows 版同名导出的脚本模板按 `process.platform` 单选;POSIX 侧 `DSH_HOME` 解析对齐执行器 env 洗刷语义(WSL2 实测)。
38
+ - 快照自动维护:定期 `git gc`(每 50 条快照或 24 小时先到先触发,`DSH_RECALL_GC_SNAPS` / `DSH_RECALL_GC_HOURS` 可调,`gc.stamp` 跨重启续存节流)。
39
+ - 会话删除联动清理:会话日志从磁盘消失后自动删除该会话全部快照 tag 并释放空间;归档不算删除,判断保守(冷会话不误清)。
40
+ - 用户自定义排除:home 下 `exclude.txt`(gitignore 语法)全局生效,下一次快照/回退即时应用。
41
+
42
+ ### 变更
43
+
44
+ - Host 代码模块化拆分(index / store / snapshots / maintenance / scripts.*),零顶层副作用,全部副作用经 `ctx.on` / `ctx.effect`。
45
+
46
+ ## [1.0.4] - 2026-08-15
47
+
48
+ ### 修复
49
+
50
+ - 非 UTF-8 代码页(GBK)输出乱码、UNC home、非 Windows 平台的通用性问题。
51
+
52
+ ## [1.0.3] - 2026-08-15
53
+
54
+ ### 修复
55
+
56
+ - 跨机器通用性:git 多候选安装位置探测、索引 base64 分块写入(突破命令行 32767 上限)、目录扫描容错(杀软锁定/异常 ACL)、路径尾分隔符归一、`DSH_HOME` 回退链。
57
+
58
+ ## [1.0.2] - 2026-08-15
59
+
60
+ ### 新增
61
+
62
+ - 未装 git / home 不可写时页面顶部一次性降级提示(gitMissing / homeFallback)。
63
+
64
+ ## [1.0.1] - 2026-08-15
65
+
66
+ ### 变更
67
+
68
+ - shell 以宿主身份(`danger-full-access`)执行:受限会话(workspace-write / read-only)也能在 home 建影子仓库、照常快照与回退。
69
+
70
+ ## [1.0.0] - 2026-08-15
71
+
72
+ ### 初始发布
73
+
74
+ - 消息撤回:影子 git 仓库快照(tag 即快照,项目目录零污染)+ 官方 `sessions.fork` 对话整段回退,原会话归档可找回。
75
+ - 确认面板先展示变更文件清单(修改/恢复/删除)再执行;`.git`、`node_modules` 自动排除;超过 100MB 的大文件跳过。
76
+ - key 冲突递减重试的 user 槽位注册,Windows PowerShell 5.1 / 7 双版本兼容。
package/README.en.md CHANGED
@@ -23,6 +23,12 @@
23
23
  | --- | --- |
24
24
  | ![Confirmation panel · file change list](docs/screenshots/confirm-panel-1.png) | ![Confirmation panel](docs/screenshots/confirm-panel-2.png) |
25
25
 
26
+ - Settings page · quick exclusion editing (Settings → Plugins → Recall Settings)
27
+
28
+ | Editing (quick add + one-click suggestions) | Saved |
29
+ | --- | --- |
30
+ | ![Settings·editing](docs/screenshots/settings-exclude-1.png) | ![Settings·saved](docs/screenshots/settings-exclude-2.png) |
31
+
26
32
  ## Highlights
27
33
 
28
34
  - **Files + conversation, rolled back together**: recalling isn't just about chat history — files the agent modified go back to their original state too.
@@ -75,7 +81,7 @@ Snapshots are fully retained as long as "the session might still be recoverable"
75
81
 
76
82
  - **Periodic gc**: every 50 snapshots or 24 hours since the last gc (whichever comes first), `git gc` runs in the background to pack loose objects. This is lossless — every snapshot remains recallable. The throttle token lives in `gc.stamp` inside the shadow repository, so restarting DSH does not reset the cycle. Both thresholds can be overridden via environment variables (rarely needed): `DSH_RECALL_GC_SNAPS`, `DSH_RECALL_GC_HOURS`.
77
83
  - **Session-deletion cleanup**: once a session is permanently deleted (its log gone from disk), the next maintenance pass automatically removes all of its snapshots and frees the space. **Archiving is not deletion** — logs of sessions archived by the recall feature itself still exist, so their snapshots are kept and recoverable from the archive. The check is conservative: a session that is merely cold (not in memory) is never cleaned, and when the log's state cannot be verified, it is left alone.
78
- - **User-defined exclusions**: put one gitignore-style pattern per line in `dsh-recall-snapshots/exclude.txt` under home (i.e. `$DSH_HOME/dsh-recall-snapshots/exclude.txt`, or `~/.dsh/dsh-recall-snapshots/exclude.txt` when unset; UTF-8; lines starting with `#` are comments), for example:
84
+ - **User-defined exclusions**: open "**Settings Plugins → Recall Settings**" to edit snapshot exclusions visually — type a path or pattern and press Enter to add it, one-click append for common patterns (`dist/`, `*.log`, `.env`, …), and saved changes take effect on the very next snapshot/recall, no restart needed. Alternatively, edit `dsh-recall-snapshots/exclude.txt` under home directly (i.e. `$DSH_HOME/dsh-recall-snapshots/exclude.txt`, or `~/.dsh/dsh-recall-snapshots/exclude.txt` when unset; UTF-8; one gitignore-style pattern per line; lines starting with `#` are comments) — both paths edit the same configuration, for example:
79
85
 
80
86
  ```gitignore
81
87
  # keep build artifacts out of snapshots
@@ -84,7 +90,7 @@ Snapshots are fully retained as long as "the session might still be recoverable"
84
90
  *.log
85
91
  ```
86
92
 
87
- This applies to all projects and takes effect on the next snapshot/recall, no restart needed. New exclusions only affect future snapshots; **when recalling to an earlier snapshot, files that weren't excluded at that time are still restored** (returning to the state as it was — that's exactly what recall means). To fully purge a directory that already made it into snapshots, manually delete the corresponding hash directory under `dsh-recall-snapshots/` in home.
93
+ This applies to all projects (when home is unwritable and a workspace falls back to in-project storage, it gets its own independent exclusion config, listed as a separate card in the settings tab). New exclusions only affect future snapshots; **when recalling to an earlier snapshot, files that weren't excluded at that time are still restored** (returning to the state as it was — that's exactly what recall means). To fully purge a directory that already made it into snapshots, manually delete the corresponding hash directory under `dsh-recall-snapshots/` in home. The settings tab requires DSH's built-in settings page (all 0.1.0-rc.x releases have it); on very old versions without the tab, editing the file directly is equivalent.
88
94
 
89
95
  ## How It Works
90
96
 
package/README.md CHANGED
@@ -23,6 +23,12 @@
23
23
  | --- | --- |
24
24
  | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板](docs/screenshots/confirm-panel-2.png) |
25
25
 
26
+ - 设置页 · 排除项快速编辑(设置 → 插件 → 撤回设置)
27
+
28
+ | 编辑中(快速添加 + 常用模式一键追加) | 保存成功 |
29
+ | --- | --- |
30
+ | ![设置页·编辑中](docs/screenshots/settings-exclude-1.png) | ![设置页·已保存](docs/screenshots/settings-exclude-2.png) |
31
+
26
32
  ## 功能亮点
27
33
 
28
34
  - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
@@ -76,7 +82,7 @@ pm2 restart <你的dsh进程名> # 若用 pm2 托管
76
82
 
77
83
  - **定期 gc**:每 50 条快照或距上次 gc 24 小时(先到先触发),后台执行 `git gc` 把 loose 对象压成 pack。无损操作——所有快照照常可回退。节流凭据写在影子仓库内的 `gc.stamp`,重启 DSH 不会重置周期。两个阈值可用环境变量覆盖(一般用不着):`DSH_RECALL_GC_SNAPS`、`DSH_RECALL_GC_HOURS`。
78
84
  - **会话删除联动清理**:会话被彻底删除(会话日志从磁盘消失)后,下一次维护会自动删除该会话的全部快照并释放空间。**归档不算删除**——撤回功能自己归档的原会话日志仍在,快照保留、随时可从归档找回。判断很保守:会话只是冷着(不内存里)不会误清;无法核实日志状态时宁可不清。
79
- - **用户自定义排除**:在 home 下 `dsh-recall-snapshots/exclude.txt`(即 `$DSH_HOME/dsh-recall-snapshots/exclude.txt`,未设置时为 `~/.dsh/dsh-recall-snapshots/exclude.txt`;UTF-8)里一行一条 gitignore 风格 pattern(`#` 开头为注释),例如:
85
+ - **用户自定义排除**:打开「**设置 → 插件 → 撤回设置**」即可可视化编辑快照排除项——输入路径或模式回车即加、常用模式(`dist/`、`*.log`、`.env` 等)一键追加、保存后下一次快照/回退立即生效,无需重启。也可以直接编辑 home 下 `dsh-recall-snapshots/exclude.txt`(即 `$DSH_HOME/dsh-recall-snapshots/exclude.txt`,未设置时为 `~/.dsh/dsh-recall-snapshots/exclude.txt`;UTF-8),一行一条 gitignore 风格 pattern(`#` 开头为注释),两种方式编辑的是同一份配置,例如:
80
86
 
81
87
  ```gitignore
82
88
  # 构建产物不进快照
@@ -85,7 +91,7 @@ pm2 restart <你的dsh进程名> # 若用 pm2 托管
85
91
  *.log
86
92
  ```
87
93
 
88
- 对所有项目生效,下一次快照/回退立即应用,无需重启。新增排除只影响之后的快照;**回退到更早的快照时,当时尚未排除的文件仍会被恢复**(回到当时的状态,这正是回退语义)。想彻底清掉已进快照的目录,可手动删除 home 下 `dsh-recall-snapshots/` 里对应项目的哈希目录。
94
+ 对所有项目生效(home 不可写而降级到项目内存储时,该工作区有独立的排除配置,设置页会分卡片列出)。新增排除只影响之后的快照;**回退到更早的快照时,当时尚未排除的文件仍会被恢复**(回到当时的状态,这正是回退语义)。想彻底清掉已进快照的目录,可手动删除 home 下 `dsh-recall-snapshots/` 里对应项目的哈希目录。设置页标签依赖 DSH 自带设置页(0.1.0-rc.x 均含);极旧版本看不到该标签时,直接编辑文件等效。
89
95
 
90
96
  ## 工作原理
91
97
 
package/lib/client.js CHANGED
@@ -62,7 +62,18 @@ window.__ModuleLoader__.load({
62
62
  '.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}',
63
63
  '.dsh-recall-toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:10000;max-width:min(560px,86vw);box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:10px 16px;font-size:13px;line-height:20px;box-shadow:0 8px 28px rgba(0,0,0,.22);display:flex;align-items:baseline;gap:8px;opacity:0;transition:opacity .25s ease;pointer-events:auto}',
64
64
  '.dsh-recall-toast.dsh-recall-toast-in{opacity:1}',
65
- '.dsh-recall-toast-tag{flex:none;font-weight:600;color:var(--dsw-alias-state-error-primary)}'
65
+ '.dsh-recall-toast-tag{flex:none;font-weight:600;color:var(--dsw-alias-state-error-primary)}',
66
+ '.dsh-recall-ex-tab{display:flex;flex-direction:column;gap:16px;max-width:720px;padding:4px 0;text-align:left}',
67
+ '.dsh-recall-ex-card{display:flex;flex-direction:column;gap:8px}',
68
+ '.dsh-recall-ex-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:22px}',
69
+ '.dsh-recall-ex-note{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;word-break:break-word}',
70
+ '.dsh-recall-ex-area{width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;font-size:12px;line-height:20px;font-family:var(--dsw-font-code, ui-monospace, SFMono-Regular, Consolas, monospace);resize:vertical;min-height:120px}',
71
+ '.dsh-recall-ex-quick{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
72
+ '.dsh-recall-ex-input{flex:1;min-width:180px;box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 10px;font-size:13px;line-height:20px}',
73
+ '.dsh-recall-ex-chip{border:none;border-radius:6px;padding:2px 8px;font-size:12px;line-height:18px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
74
+ '.dsh-recall-ex-chip:hover{color:var(--dsw-alias-label-primary)}',
75
+ '.dsh-recall-ex-status{margin-right:auto;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
76
+ '.dsh-recall-ex-status-error{color:var(--dsw-alias-state-error-primary)}'
66
77
  ].join('')
67
78
  // 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
68
79
  const stylesSvc = ctx.get('styles')
@@ -433,6 +444,137 @@ window.__ModuleLoader__.load({
433
444
  )
434
445
  }
435
446
 
447
+ // ---- 设置页「撤回设置」标签:exclude.txt 可视化快速编辑 ----
448
+
449
+ // 常用排除建议:一键追加的高频项,覆盖构建产物/日志/密钥三类最
450
+ // 常见诉求;已存在的条目自动从候选里滤掉,避免重复点击堆叠。
451
+ const EXCLUDE_SUGGESTIONS = ['dist/', 'build/', 'out/', 'coverage/', '*.log', '.env']
452
+
453
+ // 单个 exclude 文件的编辑卡片。draft/baseline 分离实现「未保存
454
+ // 修改」判定(textarea 所见即将保存的原文,不偷偷规范化);
455
+ // key=file.path 挂载,父级重载列表时整卡重建、草稿随之丢弃。
456
+ function ExcludeCard(props) {
457
+ const file = props.file
458
+ const [draft, setDraft] = React.useState(file.content || '')
459
+ const [baseline, setBaseline] = React.useState(file.content || '')
460
+ const [quick, setQuick] = React.useState('')
461
+ const [state, setState] = React.useState({ busy: false, message: '', error: false })
462
+ const dirty = draft !== baseline
463
+
464
+ // 追加一条模式:先补齐行尾换行再拼接,保证每条模式独占一行
465
+ // (exclude.txt 按行解析,两条挤一行会双双失效)
466
+ function appendPattern(pattern) {
467
+ setDraft((d) => (d && !d.endsWith('\n') ? d + '\n' : d) + pattern + '\n')
468
+ }
469
+
470
+ function addQuick() {
471
+ const t = quick.trim()
472
+ if (!t) return
473
+ appendPattern(t)
474
+ setQuick('')
475
+ }
476
+
477
+ function save() {
478
+ if (state.busy || !dirty) return
479
+ setState({ busy: true, message: '保存中…', error: false })
480
+ api('exclude-set', { path: file.path, content: draft }).then((res) => {
481
+ if (res && res.ok) {
482
+ setBaseline(draft)
483
+ setState({ busy: false, message: '已保存,下一次快照 / 预览 / 回退时生效', error: false })
484
+ } else {
485
+ setState({ busy: false, message: (res && res.error) || '保存失败', error: true })
486
+ }
487
+ }).catch((error) => {
488
+ setState({ busy: false, message: String(error), error: true })
489
+ })
490
+ }
491
+
492
+ function discard() {
493
+ if (state.busy) return
494
+ setDraft(baseline)
495
+ setState({ busy: false, message: '', error: false })
496
+ }
497
+
498
+ const draftLines = draft.split('\n').map((l) => l.trim())
499
+ const suggestions = EXCLUDE_SUGGESTIONS.filter((s) => draftLines.indexOf(s) < 0)
500
+
501
+ return React.createElement('div', { className: 'dsh-recall-ex-card' },
502
+ React.createElement('div', { className: 'dsh-recall-ex-title' }, '快照排除项'),
503
+ React.createElement('div', { className: 'dsh-recall-ex-note' },
504
+ file.home
505
+ ? '此配置全局共享,对所有工作区的快照生效(存储位置:' + file.path + ')。'
506
+ : 'home 目录不可写时此工作区降级存储,排除配置独立生效(存储位置:' + file.path + ')。'
507
+ ),
508
+ React.createElement('div', { className: 'dsh-recall-ex-note' }, 'gitignore 语法,一行一条,支持 # 注释;命中排除的文件与目录不进入快照,也不会被回退触碰。'),
509
+ React.createElement('textarea', {
510
+ className: 'dsh-recall-ex-area',
511
+ value: draft,
512
+ spellCheck: false,
513
+ onChange: (e) => setDraft(e.target.value)
514
+ }),
515
+ React.createElement('div', { className: 'dsh-recall-ex-quick' },
516
+ React.createElement('input', {
517
+ className: 'dsh-recall-ex-input',
518
+ value: quick,
519
+ placeholder: '输入路径或模式,回车快速添加',
520
+ onChange: (e) => setQuick(e.target.value),
521
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); addQuick() } }
522
+ }),
523
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: addQuick }, '添加'),
524
+ ...suggestions.map((s) => React.createElement('button', {
525
+ key: 'chip-' + s,
526
+ type: 'button',
527
+ className: 'dsh-recall-ex-chip',
528
+ title: '点击追加 ' + s,
529
+ onClick: () => appendPattern(s)
530
+ }, s))
531
+ ),
532
+ React.createElement('div', { className: 'dsh-recall-panel-actions' },
533
+ state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
534
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: !dirty || state.busy, onClick: discard }, '放弃修改'),
535
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn dsh-recall-btn-danger', disabled: !dirty || state.busy, onClick: save }, '保存')
536
+ )
537
+ )
538
+ }
539
+
540
+ // 「插件」设置分区的标签页主体:拉取 Host 枚举的 exclude 文件列表
541
+ // (home 存储通常合并为一条,降级工作区各一条)。标签页被选中后
542
+ // 由设置外壳保持挂载,本地草稿在标签间切换时不丢失。
543
+ function RecallSettingsTab() {
544
+ const [files, setFiles] = React.useState(null)
545
+ const [error, setError] = React.useState('')
546
+
547
+ function load() {
548
+ api('exclude-get', {}).then((res) => {
549
+ if (res && res.ok) { setFiles(res.files || []); setError(''); return }
550
+ if (res && res.unsupported) { setError('当前平台不支持快照功能,排除配置不可用。'); return }
551
+ setError((res && res.error) || '无法读取排除配置')
552
+ }).catch((e) => setError(String(e)))
553
+ }
554
+
555
+ React.useEffect(() => { load() }, [])
556
+
557
+ let body = null
558
+ if (error) {
559
+ body = React.createElement('div', { className: 'dsh-recall-ex-card' },
560
+ React.createElement('div', { className: 'dsh-recall-ex-note' }, error),
561
+ React.createElement('div', { className: 'dsh-recall-panel-actions' },
562
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: load }, '重试')
563
+ )
564
+ )
565
+ } else if (files === null) {
566
+ body = React.createElement('div', { className: 'dsh-recall-ex-note' }, '正在加载排除配置…')
567
+ } else if (!files.length) {
568
+ // 全新安装且从未发过消息:store 还没建,先告诉用户怎么触发
569
+ body = React.createElement('div', { className: 'dsh-recall-ex-note' }, '尚未创建任何快照存储:在任意工作区发送一条消息后,这里会出现可编辑的排除配置。')
570
+ } else {
571
+ body = React.createElement('div', { className: 'dsh-recall-ex-card' },
572
+ ...files.map((f) => React.createElement(ExcludeCard, { key: f.path, file: f }))
573
+ )
574
+ }
575
+ return React.createElement('div', { className: 'dsh-recall-ex-tab' }, body)
576
+ }
577
+
436
578
  // keyed slot 显式用低于默认 0 的 priority 注册:默认 0 通常被平台/
437
579
  // 其他插件的渲染器占据,不指定 priority 会因 keyed slot 冲突拒载
438
580
  // 整个插件;lowest renders,负值恰好覆盖默认渲染器实现撤回 UI。
@@ -450,6 +592,21 @@ window.__ModuleLoader__.load({
450
592
  if (priority === -3) console.error('[dsh-recall-plugin] slot register failed:', error)
451
593
  }
452
594
  }
595
+
596
+ // 「插件」设置分区挂自己的标签页:settings.plugins.tab 是 root 级
597
+ // list slot,id 唯一即无冲突(与 keyed slot 的 priority 冲突是两套
598
+ // 语义),order 20 排在官方「插件配置」(0) 与「插件清单」(10) 之后。
599
+ // 该 slot 由 ui-settings-plugins 声明;未组装设置页的部署里 inject
600
+ // 只是永不匹配的待定注册,天然无害。try/catch 是防御性兜底——注册
601
+ // 失败绝不该拖垮撤回按钮的主功能。
602
+ try {
603
+ slots.inject('settings.plugins.tab', () => slots.register(
604
+ { name: 'settings.plugins.tab', id: 'dsh-recall', order: 20, label: '撤回设置' },
605
+ RecallSettingsTab
606
+ ))
607
+ } catch (error) {
608
+ console.error('[dsh-recall-plugin] settings tab register failed:', error)
609
+ }
453
610
  }
454
611
  }
455
612
  }
package/lib/index.js CHANGED
@@ -50,6 +50,51 @@ export function apply(ctx) {
50
50
  res.end(JSON.stringify(body))
51
51
  }
52
52
 
53
+ // 枚举当前全部已知 exclude 文件并按路径去重:home 存储全局共享一份
54
+ // (所有工作区通常合并成一条),降级存储各自独立。根来源取并集——
55
+ // 会话注册表(当前活跃的工作区)+ state.stores 缓存(历史会话预热过、
56
+ // 可能已关闭的工作区),让设置页也能编辑非活跃项目的排除配置。
57
+ // exclude-get 直接消费结果;exclude-set 用它做路径白名单校验,客户端
58
+ // 只能回传 get 下发过的路径,堵死「借 API 写任意文件」的通道。
59
+ async function listExcludeFiles() {
60
+ const roots = new Set(state.stores.keys())
61
+ for (const session of ctx.sessions.list()) {
62
+ const cwd = session && session.header && session.header.cwd
63
+ if (cwd) roots.add(cwd)
64
+ }
65
+ const byFile = new Map()
66
+ for (const root of roots) {
67
+ try {
68
+ const store = await rt.resolveStore(root)
69
+ if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
70
+ byFile.get(store.excludeFile).roots.push(root)
71
+ } catch (error) {
72
+ /* 单个根解析失败只影响它自己,不拖垮整个列表 */
73
+ }
74
+ }
75
+ // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
76
+ // 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑(哪怕从未
77
+ // 写过、内容为空);容器不在 ⇒ 全新安装,让设置页显示引导文案。
78
+ // 注册表扫描命中的同路径条目优先(roots 信息更全),这里只补缺。
79
+ try {
80
+ const container = await rt.resolveHomeContainer()
81
+ if (container) {
82
+ const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
83
+ if (probe === 'YES') {
84
+ const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
85
+ if (!byFile.has(excludeFile)) {
86
+ // 伪 store:仅承载 readExclude/writeExclude 用到的 excludeFile
87
+ // 与 home 两个字段;不进 state.stores(无对应 root,不污染缓存)
88
+ byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
89
+ }
90
+ }
91
+ }
92
+ } catch (error) {
93
+ /* 兜底失败退回注册表结果 */
94
+ }
95
+ return byFile
96
+ }
97
+
53
98
  ctx.effect(() => webServer.register({
54
99
  kind: 'prefix',
55
100
  path: '/api/recall',
@@ -119,6 +164,41 @@ export function apply(ctx) {
119
164
  }
120
165
  return
121
166
  }
167
+ if (name === 'exclude-get') {
168
+ // 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
169
+ // 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
170
+ if (!supported) { sendJson(res, 200, { ok: false, unsupported: true }); return }
171
+ try {
172
+ const byFile = await listExcludeFiles()
173
+ const files = []
174
+ for (const [path, info] of byFile) {
175
+ let content = ''
176
+ try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
177
+ files.push({ path, home: Boolean(info.store.home), roots: info.roots, content })
178
+ }
179
+ sendJson(res, 200, { ok: true, files })
180
+ } catch (error) {
181
+ sendJson(res, 200, { ok: false, error: String(error) })
182
+ }
183
+ return
184
+ }
185
+ if (name === 'exclude-set') {
186
+ if (!supported) { sendJson(res, 200, { ok: false, unsupported: true }); return }
187
+ const path = args && args.path ? String(args.path) : ''
188
+ const content = args && typeof args.content === 'string' ? args.content : ''
189
+ try {
190
+ // 路径白名单:重新枚举当前已知 exclude 文件并要求精确命中,
191
+ // 客户端伪造的任意路径在这里被拒(见 listExcludeFiles 注释)
192
+ const byFile = await listExcludeFiles()
193
+ const info = byFile.get(path)
194
+ if (!info) { sendJson(res, 200, { ok: false, error: '未知的排除文件路径' }); return }
195
+ await snaps.writeExclude(info.store, content)
196
+ sendJson(res, 200, { ok: true })
197
+ } catch (error) {
198
+ sendJson(res, 200, { ok: false, error: String(error) })
199
+ }
200
+ return
201
+ }
122
202
  sendJson(res, 404, { ok: false, error: 'unknown endpoint: ' + name })
123
203
  } catch (error) {
124
204
  sendJson(res, 500, { ok: false, error: String(error) })
@@ -288,3 +288,16 @@ export function indexReadCmd(dir) {
288
288
  export function legacyRmScript(path) {
289
289
  return 'rm -rf -- ' + psq(path)
290
290
  }
291
+
292
+ // exclude.txt 原文读取(设置页编辑用):缺失文件 cat 报错走 2>/dev/null ||
293
+ // true 吞掉输出空串——与 pwsh 版同语义,按「尚未配置」处理;写入不走
294
+ // 模板函数,调用方直接 cat > file + stdin(同 saveIndex 的 POSIX 分支)。
295
+ export function excludeReadCmd(file) {
296
+ return 'cat ' + psq(file) + ' 2>/dev/null || true'
297
+ }
298
+
299
+ // 目录存在探测:YES/NO 定长标记与 pwsh 版逐字同语义(容器路径本身在
300
+ // JS 侧解析,POSIX 不需要 homeContainerScript 的 shell 版)。
301
+ export function dirExistsScript(dir) {
302
+ return '[ -d ' + psq(dir) + ' ] && echo YES || echo NO'
303
+ }
@@ -332,3 +332,39 @@ export function indexReadCmd(dir) {
332
332
  export function legacyRmScript(path) {
333
333
  return 'Remove-Item -Recurse -Force -LiteralPath ' + psq(path)
334
334
  }
335
+
336
+ // exclude.txt 原文读取(设置页编辑用):-Raw 保留换行与空行结构,让用户
337
+ // 看到的就是落盘原文;文件不存在时 SilentlyContinue 输出空串,JS 侧按
338
+ // 「尚未配置」处理——设置页在快照存储刚建好、exclude.txt 还没写过时也会打开。
339
+ export function excludeReadCmd(file) {
340
+ return 'Get-Content -LiteralPath ' + psq(file) + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
341
+ }
342
+
343
+ // 快照容器目录(<homeBase>/dsh-recall-snapshots,不含哈希子目录):$h
344
+ // 解析链与 homeDirScript 逐字一致(DSH_HOME 优先级不能漂移,否则兜底
345
+ // 扫描会看错目录)。设置页 exclude-get 的磁盘兜底用(见 store.js
346
+ // resolveHomeContainer):冷启动会话注册表为空时,只要容器在磁盘上
347
+ // 存在,共享 exclude.txt 就该可编辑。
348
+ export function homeContainerScript(envHome) {
349
+ return [
350
+ "$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
351
+ "Write-Output (Join-Path $h 'dsh-recall-snapshots')"
352
+ ].join('\n')
353
+ }
354
+
355
+ // 目录存在探测:输出定长 YES/NO 标记(与 posix 版逐字同语义),
356
+ // JS 侧统一按 'YES' 判定,不依赖退出码——runShell 对非零退出直接抛错。
357
+ export function dirExistsScript(dir) {
358
+ return "if (Test-Path -LiteralPath " + psq(dir) + " -PathType Container) { Write-Output 'YES' } else { Write-Output 'NO' }"
359
+ }
360
+
361
+ // exclude.txt 分块写入:与 indexWriteCmd 同款 base64 分块策略——Windows
362
+ // 命令行 32767 字符上限同样约束设置页保存的任意长度配置,piece/first 语义
363
+ // 一致(首块 Set-Content 覆盖、续块 Add-Content 追加)。父目录由调用方先
364
+ // mkdirScript 兜底,这里不重复建目录。
365
+ export function excludeWriteCmd(file, piece, first) {
366
+ if (first) {
367
+ return piece + 'Set-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
368
+ }
369
+ return piece + 'Add-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
370
+ }
package/lib/snapshots.js CHANGED
@@ -66,6 +66,37 @@ export function createSnapshots(ctx, rt) {
66
66
  }
67
67
  }
68
68
 
69
+ // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
70
+ // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
71
+ // 文件都输出空串,这里不用区分「没配过」和「配了空」。
72
+ async function readExclude(store) {
73
+ return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
74
+ }
75
+
76
+ // exclude.txt 原文写入(设置页保存):平台分叉与 saveIndex 完全同构——
77
+ // win32 走 base64 分块(命令行 32767 上限),POSIX 走 stdin 直写全文。
78
+ // 先 mkdir 父目录兜底:home 根目录/降级 store 目录被用户手滑删掉时,
79
+ // 保存不该因此失败。空内容也要落一次写(清空配置是合法操作),所以
80
+ // base64 为空串时仍发一块空 piece,而不是整段跳过留下旧文件。
81
+ async function writeExclude(store, text) {
82
+ const body = String(text == null ? '' : text)
83
+ const sep = rt.isWin ? '\\' : '/'
84
+ const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
85
+ await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
86
+ if (rt.isWin) {
87
+ const b64 = Buffer.from(body, 'utf8').toString('base64')
88
+ const chunks = b64 ? b64.match(/.{1,20000}/g) : ['']
89
+ let first = true
90
+ for (const chunk of chunks) {
91
+ const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + chunk + "')) | "
92
+ await rt.runShell(S.excludeWriteCmd(store.excludeFile, piece, first), { stdoutMaxBytes: 4096 })
93
+ first = false
94
+ }
95
+ } else {
96
+ await rt.runShell('cat > ' + S.psq(store.excludeFile), { stdin: body, stdoutMaxBytes: 4096 })
97
+ }
98
+ }
99
+
69
100
  // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
70
101
  async function rebuildOrphans(root, sessionId) {
71
102
  const store = state.stores.get(root)
@@ -188,5 +219,5 @@ export function createSnapshots(ctx, rt) {
188
219
  return result
189
220
  }
190
221
 
191
- return { saveIndex, loadIndex, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
222
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
192
223
  }
package/lib/store.js CHANGED
@@ -37,7 +37,8 @@ export function createRuntime(ctx) {
37
37
  gcLastAt: new Map(),
38
38
  gcCount: new Map(),
39
39
  gitExe: null,
40
- posixHomeBase: null
40
+ posixHomeBase: null,
41
+ homeContainer: null
41
42
  }
42
43
 
43
44
  // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
@@ -125,7 +126,7 @@ export function createRuntime(ctx) {
125
126
  // DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
126
127
  // (宿主进程 env,用户导出可见)与 os.homedir()。哈希用 Node crypto
127
128
  // 统一算,规避 Linux sha256sum / macOS shasum 的二选一移植成本。
128
- async function homeDirForPosix(root) {
129
+ async function posixHomeBaseResolve() {
129
130
  if (state.posixHomeBase === null) {
130
131
  let probed = ''
131
132
  try {
@@ -135,14 +136,47 @@ export function createRuntime(ctx) {
135
136
  }
136
137
  state.posixHomeBase = probed || process.env.DSH_HOME || os.homedir()
137
138
  }
139
+ return state.posixHomeBase
140
+ }
141
+
142
+ async function homeDirForPosix(root) {
143
+ const base = await posixHomeBaseResolve()
138
144
  const hash = crypto.createHash('sha256').update(root, 'utf8').digest('hex')
139
- return state.posixHomeBase.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
145
+ return base.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
140
146
  }
141
147
 
142
148
  async function homeDirFor(root) {
143
149
  return isWin ? homeDirForWin(root) : homeDirForPosix(root)
144
150
  }
145
151
 
152
+ // 快照容器目录(<homeBase>/dsh-recall-snapshots,不含哈希子目录):
153
+ // 设置页 exclude-get 的磁盘兜底用——冷启动时会话注册表为空(惰性
154
+ // 载入),但容器目录可能早已存在,此时共享 exclude.txt 仍应可编辑。
155
+ // win 的 $h 解析在 shell 侧(homeContainerScript 与 homeDirScript
156
+ // 同链);POSIX 复用 posixHomeBaseResolve。失败返回 null 且不缓存,
157
+ // 下次调用自然重试。
158
+ async function resolveHomeContainer() {
159
+ if (state.homeContainer) return state.homeContainer
160
+ let container = null
161
+ try {
162
+ if (isWin) {
163
+ const envHome = (process.env && process.env.DSH_HOME) || ''
164
+ const text = scripts.stripBom(await runShell(scripts.homeContainerScript(envHome), { stdoutMaxBytes: 4096 })).trim()
165
+ if (text) {
166
+ // UNC 前缀保留、内部连续反斜杠折叠——与 homeDirForWin 同规则
167
+ container = /^\\\\/.test(text) ? '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\') : text.replace(/\\{2,}/g, '\\')
168
+ }
169
+ } else {
170
+ const base = await posixHomeBaseResolve()
171
+ container = base.replace(/\/+$/, '') + '/dsh-recall-snapshots'
172
+ }
173
+ } catch (error) {
174
+ container = null
175
+ }
176
+ if (container) state.homeContainer = container
177
+ return container
178
+ }
179
+
146
180
  // store 形态装配:exclude.txt 是用户自定义排除文件,home 存储时放在
147
181
  // dsh-recall-snapshots 根(所有项目共享一份全局配置);降级存储时放
148
182
  // store 目录内部——降级目录本身已被排除规则覆盖,不再往项目根塞文件。
@@ -245,5 +279,5 @@ export function createRuntime(ctx) {
245
279
  runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
246
280
  }
247
281
 
248
- return { state, isWin, scripts, runShell, resolveRoot, resolveGit, homeDirFor, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
282
+ return { state, isWin, scripts, runShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
249
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  "cordis.patch.yml",
22
22
  "README.md",
23
23
  "README.en.md",
24
+ "CHANGELOG.md",
24
25
  "LICENSE"
25
26
  ],
26
27
  "engines": {