dsh-recall-plugin 1.0.0 → 1.0.2

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,93 +1,99 @@
1
- # dsh-recall-plugin
2
-
3
- > 撤回一条消息,项目文件也一起回去。
4
-
5
- ![License](https://img.shields.io/badge/license-MIT-blue)
6
- ![Platform](https://img.shields.io/badge/platform-Windows-blue)
7
- ![DSH](https://img.shields.io/badge/DSH-0.1.0--rc-blue)
8
- ![Build](https://img.shields.io/badge/%E7%BA%AFJS-%E9%9B%B6%E6%9E%84%E5%BB%BA-green)
9
-
10
- ---
11
-
12
- - 在任意一条你发过的消息下方,点「↶ 撤回」,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
13
- - 注:安装本插件前的消息无法撤回
14
-
15
- ## 界面预览
16
- - 撤回按钮位置
17
-
18
- ![悬停出现撤回按钮](docs/screenshots/recall-button.png)
19
-
20
- ---
21
- | 确认面板 · 变更文件清单| |
22
- | --- | --- |
23
- | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板](docs/screenshots/confirm-panel-2.png) |
24
-
25
- ## 功能亮点
26
-
27
- - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
28
- - **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
29
- - **项目目录保持干净**:快照默认存在 `$DSH_HOME` 下,不会往项目里塞任何东西;受限会话写不了 home 时才临时降级到项目内 `.dsh-recall-snapshots`,权限为 Full access 时自动迁走、清理干净。
30
- - **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
31
- - **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
32
- - **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
33
-
34
- ## 安装
35
-
36
- 前置:Windows + git CLI(未装 git 时插件静默降级,不显示撤回按钮,不影响 DSH 运行);PowerShell 5.1 / 7 均可;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
37
-
38
- ```powershell
39
- # DSH 官方插件命令:安装并自动挂载进 web profile
40
- dsh plugin --profile web add dsh-recall-plugin@<version>
41
- # 未发布时也可从 git 安装(纯 JS 无构建,免 prepare/allowBuilds):
42
- # dsh plugin --profile web add github:<user>/dsh-recall-plugin
43
-
44
- # 重启 DSH 进程(按你的启动方式,任选其一)
45
- dsh web # 前台直接启动
46
- pm2 restart <你的dsh进程名> # 若用 pm2 托管
47
- ```
48
-
49
- **验证**:重启后硬刷新页面(Ctrl+Shift+R),悬停任意一条插件启用后发送的用户消息——复制按钮旁出现「↶」即生效。没有按钮?九成是没重启 DSH 进程,或 git CLI 不在 PATH 里。
50
-
51
- **卸载**:`dsh plugin --profile web remove dsh-recall-plugin`(同时移除依赖与挂载层),快照数据保留在 `$DSH_HOME/dsh-recall-snapshots/`,想彻底清除手动删掉该目录即可。
52
-
53
- ## 使用
54
-
55
- 1. 鼠标悬停任意**插件启用后发送**的用户消息,复制按钮左侧出现「↶ 撤回」。
56
- 2. 点击 → 确认面板展示将变更的文件清单(修改 / 恢复 / 删除)。
57
- 3. 点「确认回退」→ 文件恢复到该消息发送前的状态;视图切到新会话(该消息及之后的对话移除),原会话归档、随时可找回。
58
-
59
- ## 工作原理
60
-
61
- 每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制安全,全程不触碰项目自身的 git 状态。
62
-
63
- - 快照存储:`$DSH_HOME/dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)与索引文件 `index.json`(消息 ID → 快照时间 / 会话)。
64
- - 想直接翻历史快照:
65
-
66
- ```powershell
67
- git --git-dir="<store>\git\.git" tag -l
68
- git --git-dir="<store>\git\.git" ls-tree -r --name-only snap-<消息ID>
69
- ```
70
-
71
- ## 已知限制
72
-
73
- - 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
74
- - 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
75
- - 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS 下插件不报错,但快照不工作、不显示撤回按钮。
76
- - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)不进快照,其内容不参与回退。
77
-
78
- ## 本地开发(无需发布)
79
-
80
- ```powershell
81
- # 把包目录放进 web profile 的 node_modules,并登记到 bundles
82
- $pkg = '<你的仓库克隆路径>\dsh-recall-plugin'
83
- $profile = "$env:USERPROFILE\.dsh\profiles\web"
84
- Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
85
- # 手动编辑 $profile\package.json:
86
- # dependencies 加 "dsh-recall-plugin": "1.0.0"
87
- # dsh.profile.bundles "dsh-recall-plugin"
88
- # 然后重启 DSH 并硬刷新页面
89
- ```
90
-
91
- ## License
92
-
93
- MIT
1
+ # dsh-recall-plugin
2
+
3
+ > 撤回一条消息,项目文件也一起回去。
4
+
5
+ ![npm](https://img.shields.io/npm/v/dsh-recall-plugin?label=npm&color=cb3837)
6
+ ![License](https://img.shields.io/badge/license-MIT-blue)
7
+ ![Platform](https://img.shields.io/badge/platform-Windows-blue)
8
+ ![DSH](https://img.shields.io/badge/DSH-0.1.0--rc-blue)
9
+ ![Build](https://img.shields.io/badge/%E7%BA%AFJS-%E9%9B%B6%E6%9E%84%E5%BB%BA-green)
10
+
11
+ ---
12
+ **在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
13
+
14
+
15
+ ## 界面预览
16
+ - 撤回按钮位置
17
+
18
+ ![悬停出现撤回按钮](docs/screenshots/recall-button.png)
19
+
20
+ ---
21
+ | 确认面板 · 变更文件清单| |
22
+ | --- | --- |
23
+ | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板](docs/screenshots/confirm-panel-2.png) |
24
+
25
+ ## 功能亮点
26
+
27
+ - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样。
28
+ - **不碰你项目自己的 git**:快照存在独立的影子 git 仓库里,你的分支、暂存区、未提交改动统统不受影响;`.git`、`node_modules` 自动排除。
29
+ - **项目目录保持干净**:快照始终存在 `$DSH_HOME` 下,不会往项目里塞任何东西;与会话的沙箱权限无关(workspace-write / read-only 会话照常快照与回退),仅当 home 本身不可写(如指到只读盘)才降级到项目内 `.dsh-recall-snapshots`(降级时页面会提示),home 恢复后自动迁走、清理干净。
30
+ - **可以反复后悔**:快照全量保留、永不修剪。撤回一次后还能再撤到更早;撤回时被覆盖的文件也一直找得回来。
31
+ - **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
32
+ - **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
33
+
34
+ ## 已知限制
35
+
36
+ - 快照在**消息发送时**创建,插件启用前的历史消息没有快照,不显示撤回按钮。
37
+ - 会话第一条用户消息无法回退对话(仅文件回退),因为 fork 需要更早的 turn 边界。
38
+ - 仅支持 Windows(脚本用 PowerShell + git CLI);Linux/macOS 下插件不报错,但快照不工作、不显示撤回按钮。
39
+ - 工作区内嵌套的其他 git 仓库(子目录自带 `.git`)不进快照,其内容不参与回退。
40
+
41
+ ## 安装
42
+
43
+ 前置:Windows + git CLI(未装 git 时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);PowerShell 5.1 / 7 均可;DSH 0.1.0-rc.x(依赖版本见 `peerDependencies`)。
44
+
45
+
46
+ - DSH 官方插件命令:安装并自动挂载进 web profile
47
+ ```powershell
48
+ dsh plugin --profile web add dsh-recall-plugin
49
+ ```
50
+ - 也可从 git 直接安装(纯 JS 无构建,免 prepare/allowBuilds):
51
+ ```powershell
52
+ dsh plugin --profile web add github:limbo947/DSH-recall-plugin
53
+ ```
54
+ - 重启 DSH 进程(按你的启动方式,任选其一)
55
+ ```powershell
56
+ dsh web # 前台直接启动
57
+ pm2 restart <你的dsh进程名> # 若用 pm2 托管
58
+ ```
59
+
60
+ **验证**:重启后硬刷新页面(Ctrl+Shift+R),悬停任意一条插件启用后发送的用户消息——复制按钮旁出现「↶」即生效。没有按钮?九成是没重启 DSH 进程,或 git CLI 不在 PATH 里。
61
+
62
+ **卸载**:`dsh plugin --profile web remove dsh-recall-plugin`(同时移除依赖与挂载层),快照数据保留在 `$DSH_HOME/dsh-recall-snapshots/`,想彻底清除手动删掉该目录即可。
63
+
64
+ ## 使用
65
+
66
+ 1. 鼠标悬停任意**插件启用后发送**的用户消息,复制按钮左侧出现「↶ 撤回」。
67
+ 2. 点击 确认面板展示将变更的文件清单(修改 / 恢复 / 删除)。
68
+ 3. 点「确认回退」→ 文件恢复到该消息发送前的状态;视图切到新会话(该消息及之后的对话移除),原会话归档、随时可找回。
69
+
70
+ ## 工作原理
71
+
72
+ 每条用户消息发送时(agent 动文件之前),工作区被快照进一个独立的影子 git 仓库;撤回时用 `git archive` 恢复文件、通过 DSH 官方 `sessions.fork` 机制把会话切到该消息之前。二进制安全,全程不触碰项目自身的 git 状态。
73
+
74
+ - 快照存储:`$DSH_HOME/dsh-recall-snapshots/<SHA256(项目绝对路径)>/`,内含影子 git 仓库(`git/`,tag 名为 `snap-<消息ID>`)与索引文件 `index.json`(消息 ID → 快照时间 / 会话)。
75
+ - 想直接翻历史快照:
76
+
77
+ ```powershell
78
+ git --git-dir="<store>\git\.git" tag -l
79
+ git --git-dir="<store>\git\.git" ls-tree -r --name-only snap-<消息ID>
80
+ ```
81
+
82
+
83
+
84
+ ## 本地开发(无需发布)
85
+
86
+ ```powershell
87
+ # 把包目录放进 web profile node_modules,并登记到 bundles
88
+ $pkg = '<你的仓库克隆路径>\dsh-recall-plugin'
89
+ $profile = "$env:USERPROFILE\.dsh\profiles\web"
90
+ Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
91
+ # 手动编辑 $profile\package.json:
92
+ # dependencies 加 "dsh-recall-plugin": "1.0.0"
93
+ # dsh.profile.bundles 加 "dsh-recall-plugin"
94
+ # 然后重启 DSH 并硬刷新页面
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
package/lib/client.js CHANGED
@@ -59,7 +59,10 @@ window.__ModuleLoader__.load({
59
59
  '.dsh-recall-btn{border:none;border-radius:8px;padding:5px 14px;font-size:13px;line-height:20px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}',
60
60
  '.dsh-recall-btn:hover{color:var(--dsw-alias-label-primary)}',
61
61
  '.dsh-recall-btn-danger{background:var(--dsw-alias-state-error-primary);color:#fff}',
62
- '.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}'
62
+ '.dsh-recall-btn-danger:hover{color:#fff;filter:brightness(1.08)}',
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
+ '.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)}'
63
66
  ].join('')
64
67
  // 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
65
68
  const stylesSvc = ctx.get('styles')
@@ -75,6 +78,37 @@ window.__ModuleLoader__.load({
75
78
  let initedSessionId = null
76
79
  let initDone = Promise.resolve()
77
80
 
81
+ // 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
82
+ // 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
83
+ const noticeShown = new Set()
84
+ function showNotice(kind, text) {
85
+ if (noticeShown.has(kind) || typeof document === 'undefined') return
86
+ noticeShown.add(kind)
87
+ try {
88
+ const el = document.createElement('div')
89
+ el.className = 'dsh-recall-toast'
90
+ const tag = document.createElement('span')
91
+ tag.className = 'dsh-recall-toast-tag'
92
+ tag.textContent = '撤回插件'
93
+ const body = document.createElement('span')
94
+ body.textContent = text
95
+ el.appendChild(tag)
96
+ el.appendChild(body)
97
+ el.addEventListener('click', () => dismiss(), { once: true })
98
+ document.body.appendChild(el)
99
+ requestAnimationFrame(() => el.classList.add('dsh-recall-toast-in'))
100
+ const timer = setTimeout(dismiss, 7000)
101
+ let dismissed = false
102
+ function dismiss() {
103
+ if (dismissed) return
104
+ dismissed = true
105
+ clearTimeout(timer)
106
+ el.classList.remove('dsh-recall-toast-in')
107
+ setTimeout(() => el.remove(), 300)
108
+ }
109
+ } catch (e) { /* 提示失败不影响主流程 */ }
110
+ }
111
+
78
112
  // 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
79
113
  // 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
80
114
  // snapshot-info 必须等它完成后再查,否则冷启动时索引尚未载入会误判
@@ -82,7 +116,15 @@ window.__ModuleLoader__.load({
82
116
  function ensureInit(sessionId) {
83
117
  if (!sessionId || initedSessionId === sessionId) return initDone
84
118
  initedSessionId = sessionId
85
- initDone = api('init', { sessionId }).catch(() => {})
119
+ initDone = api('init', { sessionId }).then((res) => {
120
+ const notice = res && res.notice
121
+ if (notice && notice.gitMissing) {
122
+ showNotice('git', '未检测到 git CLI,撤回功能不可用(快照引擎依赖 git)。安装 git 并重启 DSH 后即可使用。')
123
+ }
124
+ if (notice && notice.homeFallback) {
125
+ showNotice('home', 'home 目录不可写,快照已降级存储到项目内 .dsh-recall-snapshots 目录。')
126
+ }
127
+ }).catch(() => {})
86
128
  return initDone
87
129
  }
88
130
 
package/lib/index.js CHANGED
@@ -40,19 +40,22 @@ export function apply(ctx) {
40
40
  return "'" + String(value).replace(/'/g, "''") + "'"
41
41
  }
42
42
 
43
- // 每次 shell 调用都带会话沙箱策略:默认受限模式下不带策略连临时目录都写不了。
44
- async function runShell(command, opts, sessionId) {
45
- let policy
43
+ // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
44
+ // 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
45
+ // 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
46
+ // 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
47
+ // 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
48
+ // 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
49
+ // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
50
+ // 回退恢复直接失败。pwsh-sandbox 对 danger-full-access 直接不约束(等价
51
+ // 本地执行器),无沙箱后端的部署则忽略该字段,两种环境都成立。
52
+ async function runShell(command, opts) {
46
53
  const sp = ctx.get('sandboxPolicy')
47
- if (sp) {
48
- const session = sessionId ? sessions.get(sessionId) : undefined
49
- policy = sp.resolve(session ? { session } : {})
50
- }
51
54
  const spec = shell.resolve({
52
55
  command,
53
56
  timeoutMs: (opts && opts.timeoutMs) || 300000,
54
57
  stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
55
- ...(policy ? { sandboxPolicy: policy } : {})
58
+ sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
56
59
  })
57
60
  const res = await shell.run(spec)
58
61
  const out = (res && res.stdout && res.stdout.text) || ''
@@ -84,18 +87,12 @@ export function apply(ctx) {
84
87
  return root
85
88
  }
86
89
 
87
- // 会话查找(按 cwd 匹配),用于索引读写与 git 操作的沙箱策略
88
- function sessionForRoot(root) {
89
- const found = sessions.list().find((s) => s && s.header && s.header.cwd === root)
90
- return found ? found.id : null
91
- }
92
-
93
90
  // 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,
94
91
  // 求值一次并缓存,脚本里用绝对路径调用,避免每条命令依赖 PATH。
95
92
  async function resolveGit() {
96
93
  if (state.gitExe !== null) return state.gitExe
97
94
  try {
98
- let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 }, null)).trim()
95
+ let path = stripBom(await runShell("$g = (Get-Command git -ErrorAction SilentlyContinue).Source; if (-not $g -and (Test-Path -LiteralPath 'C:\\Program Files\\Git\\cmd\\git.exe')) { $g = 'C:\\Program Files\\Git\\cmd\\git.exe' }; if ($g) { Write-Output $g }", { stdoutMaxBytes: 4096 })).trim()
99
96
  state.gitExe = path || ''
100
97
  } catch (error) {
101
98
  state.gitExe = ''
@@ -115,7 +112,7 @@ export function apply(ctx) {
115
112
  "$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
116
113
  "Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
117
114
  ].join('\n')
118
- const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 }, sessionId)).trim()
115
+ const text = stripBom(await runShell(dirScript, { stdoutMaxBytes: 4096 })).trim()
119
116
  if (!text) return null
120
117
  // PS 的 Join-Path 可能带出连续反斜杠(旧版脚本遗留 "…\\<hex>" 形态);
121
118
  // 折叠成单反斜杠。Windows 把中间的重复分隔符视为同一个目录,
@@ -123,8 +120,9 @@ export function apply(ctx) {
123
120
  return text.replace(/\\{2,}/g, '\\')
124
121
  }
125
122
 
126
- // 存储根:优先放 DSH home(保持项目目录干净);
127
- // workspace-write 等受限会话写不了 home 时自动降级到项目内(功能优先于干净)。
123
+ // 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
124
+ // 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
125
+ // (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
128
126
  // git init <dir> 会把真实 git-dir 建在 <dir>\.git,所以 store.repo 是
129
127
  // 仓库工作目录、store.git 是真实 git-dir——冒烟测试踩过的坑。
130
128
  async function resolveStore(root, sessionId) {
@@ -138,7 +136,7 @@ export function apply(ctx) {
138
136
  }
139
137
  if (homeDir) {
140
138
  try {
141
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
139
+ await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
142
140
  const store = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
143
141
  state.stores.set(root, store)
144
142
  return store
@@ -147,15 +145,15 @@ export function apply(ctx) {
147
145
  }
148
146
  }
149
147
  const fallback = root + '\\.dsh-recall-snapshots'
150
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
148
+ await runShell('New-Item -ItemType Directory -Force -Path ' + psq(fallback) + ' | Out-Null', { stdoutMaxBytes: 4096 })
151
149
  const store = { dir: fallback, repo: fallback + '\\git', git: fallback + '\\git\\.git', home: false }
152
150
  state.stores.set(root, store)
153
151
  return store
154
152
  }
155
153
 
156
- // 降级后自动升级:会话权限提升(home 恢复可写)时,把项目内的
157
- // 影子仓库整体迁回 home 并删除项目内目录,恢复“项目目录干净”。
158
- // 失败节流 5 分钟,避免受限会话里每条消息都白试一次。
154
+ // 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
155
+ // 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
156
+ // 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
159
157
  async function tryUpgradeToHome(root, sessionId) {
160
158
  const store = state.stores.get(root)
161
159
  if (!store || store.home) return store
@@ -171,7 +169,7 @@ export function apply(ctx) {
171
169
  }
172
170
  if (!homeDir) return store
173
171
  try {
174
- await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 }, sessionId)
172
+ await runShell('New-Item -ItemType Directory -Force -Path ' + psq(homeDir) + ' | Out-Null', { stdoutMaxBytes: 4096 })
175
173
  const migrate = [
176
174
  "$ErrorActionPreference = 'Stop'",
177
175
  '$src = ' + psq(store.dir),
@@ -181,7 +179,7 @@ export function apply(ctx) {
181
179
  'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
182
180
  "Write-Output 'MIGRATE_OK'"
183
181
  ].join('\n')
184
- await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 }, sessionId)
182
+ await runShell(migrate, { timeoutMs: 300000, stdoutMaxBytes: 4096 })
185
183
  const upgraded = { dir: homeDir, repo: homeDir + '\\git', git: homeDir + '\\git\\.git', home: true }
186
184
  state.stores.set(root, upgraded)
187
185
  state.gitReady.delete(store.git)
@@ -223,7 +221,7 @@ export function apply(ctx) {
223
221
  "Write-Output 'GIT_OK'"
224
222
  ].join('\n')
225
223
  try {
226
- await runShell(script, { stdoutMaxBytes: 4096 }, sessionId)
224
+ await runShell(script, { stdoutMaxBytes: 4096 })
227
225
  state.gitReady.add(store.git)
228
226
  return true
229
227
  } catch (error) {
@@ -402,7 +400,7 @@ export function apply(ctx) {
402
400
  const json = JSON.stringify(entries)
403
401
  try {
404
402
  const b64 = Buffer.from(json, 'utf8').toString('base64')
405
- await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 }, sessionId)
403
+ await runShell("New-Item -ItemType Directory -Force -Path " + psq(store.dir) + " | Out-Null; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "')) | Set-Content -LiteralPath " + psq(store.dir + '\\index.json') + ' -Encoding utf8 -NoNewline', { stdoutMaxBytes: 4096 })
406
404
  } catch (error) {
407
405
  console.error('recall saveIndex failed:', String(error))
408
406
  }
@@ -414,7 +412,7 @@ export function apply(ctx) {
414
412
  const store = state.stores.get(root)
415
413
  if (!store) return
416
414
  try {
417
- const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 }, sessionId)).trim()
415
+ const raw = stripBom(await runShell('Get-Content -LiteralPath ' + psq(store.dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue', { stdoutMaxBytes: 4194304 })).trim()
418
416
  if (!raw) return
419
417
  const entries = JSON.parse(raw)
420
418
  if (!Array.isArray(entries)) return
@@ -438,7 +436,7 @@ export function apply(ctx) {
438
436
  const gitExe = await resolveGit()
439
437
  if (!store || !gitExe) return
440
438
  try {
441
- const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 }, sessionId)).trim()
439
+ const listing = stripBom(await runShell(listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
442
440
  if (!listing) return
443
441
  for (const name of listing.split(/\r?\n/)) {
444
442
  const id = name.trim().replace(/^snap-/, '')
@@ -456,7 +454,7 @@ export function apply(ctx) {
456
454
  function cleanupLegacy(root, sessionId) {
457
455
  const store = state.stores.get(root)
458
456
  if (!store || !store.home) return
459
- runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }, sessionId).catch(() => {})
457
+ runShell('Remove-Item -Recurse -Force -LiteralPath ' + psq(root + '\\.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
460
458
  }
461
459
 
462
460
  async function captureSnapshot(sessionId, messageId, time) {
@@ -468,7 +466,7 @@ export function apply(ctx) {
468
466
  if (!ok) return
469
467
  await loadIndex(root, sessionId)
470
468
  try {
471
- await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }, sessionId)
469
+ await runShell(snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
472
470
  state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
473
471
  await saveIndex(root, sessionId)
474
472
  } catch (error) {
@@ -481,7 +479,7 @@ export function apply(ctx) {
481
479
  if (!snap) return null
482
480
  const store = state.stores.get(snap.root)
483
481
  if (!store) return null
484
- const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }, snap.sessionId))
482
+ const text = stripBom(await runShell(diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }))
485
483
  const trimmed = text.trim()
486
484
  if (!trimmed) return []
487
485
  const parsed = JSON.parse(trimmed)
@@ -495,7 +493,7 @@ export function apply(ctx) {
495
493
  if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
496
494
  const store = state.stores.get(snap.root)
497
495
  if (!store) return { ok: false, error: '快照存储不可用' }
498
- const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }, snap.sessionId))
496
+ const text = stripBom(await runShell(rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
499
497
  const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
500
498
  const deleted = m ? parseInt(m[1], 10) : 0
501
499
  const restored = m ? parseInt(m[2], 10) : 0
@@ -572,6 +570,7 @@ export function apply(ctx) {
572
570
  if (name === 'init') {
573
571
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
574
572
  const root = await resolveRoot(sessionId)
573
+ let notice = null
575
574
  if (root) {
576
575
  let store = await resolveStore(root, sessionId)
577
576
  store = await tryUpgradeToHome(root, sessionId)
@@ -579,8 +578,15 @@ export function apply(ctx) {
579
578
  await loadIndex(root, sessionId)
580
579
  await rebuildOrphans(root, sessionId)
581
580
  cleanupLegacy(root, sessionId)
581
+ // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
582
+ // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
583
+ // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
584
+ notice = {
585
+ gitMissing: state.gitExe === '',
586
+ homeFallback: store ? !store.home : false
587
+ }
582
588
  }
583
- sendJson(res, 200, { ok: Boolean(root), root: root || null })
589
+ sendJson(res, 200, { ok: Boolean(root), root: root || null, notice })
584
590
  return
585
591
  }
586
592
  if (name === 'snapshot-info') {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/limbo947/dsh-recall-plugin"
8
+ "url": "git+https://github.com/limbo947/DSH-recall-plugin.git"
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public"
@@ -49,7 +49,9 @@
49
49
  "react": "^18.2.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
- "cordis": { "optional": true }
52
+ "cordis": {
53
+ "optional": true
54
+ }
53
55
  },
54
56
  "license": "MIT"
55
57
  }