dsh-recall-plugin 2.3.24 → 2.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [2.4.2] - 2026-09-23
6
+
7
+ ### 修复
8
+
9
+ - **CI 单测缺 `@deepseek-ai/schemastery` 致 4 个测试文件收集失败**:`src/host/config.ts` 在运行时裸导入 `@deepseek-ai/schemastery`(Config schema 本体),但它此前只是 peerDependency——本机靠工作区 junction 解析所以本地全绿,CI `npm ci --legacy-peer-deps` 不装 peer 即 `ERR_MODULE_NOT_FOUND`。M2 起 manage-list/manage-list-stale/manage-usage/settings-bridge 四个单测文件经 routes-manage/config 链传递依赖 config.ts,而 0.1.7 适配批次(M1 起)当天才首次推送过 CI,问题首次暴露。修复=schemastery 入 devDependencies(`^3.18.3`,与 `.volatile()` feature-detect 的下限对齐;peer 声明 `^3.18.1` 不动)。**对 2.4.1 已发布包零影响**:生产环境 schemastery 由宿主 dsh 提供,此缺包只影响 CI/裸克隆环境的单测。AGENTS.md junction 约定同步更新(schemastery 不再需要 junction;npm install 会修剪 dsh-settings junction,已记录重建方法)。
10
+
11
+ ### 变更
12
+
13
+ - **兼容声明同步 dsh 0.1.7-alpha.2(零破坏核验,无代码改动)**:本版为体验/稳健性版本,插件消费面经全树内容级 diff 实证零破坏——`updateQueue`/`readAttachment`/`QueueAction`(含 `{kind:'remove'}`)签名行逐字一致,`conversation.chat.node` 与 `ConversationNode`/`records` 槽位契约字节未变,`__ModuleLoader__.load({id, factory})` 注册形态与 `plugins.bundle.config` 在位,fork 实现与切点校验未动;`dsh-session`/`dsh-shell`/`dsh-pwsh-local`/`dsh-sandbox-policy`/`dsh-host-webserver`/`dsh-client-connection`/`dsh-agent{,-loop}` 与整个 cordis vendor 栈(含 `cordis-plugin-loader`)字节级未变,改动全部落在官方 UI/工具层(滚动跟随、历史分页 Turn 对齐、本地回声记账、队列消息重编辑、持久 pwsh 等待修复、工具结果 token 预算)。门禁:`test:probe` 46/46、`npm test` 430/430、`verify:host` 装配断言全过、`typecheck` 通过。同步项:`dshReleases` 补 `0.1.7-alpha.2: compatible`(peer 范围 `>=0.1.7-alpha.1 <0.1.8` 天然覆盖)、`docs/reference/` 镜像按 `dsh-v0.1.7-alpha.2` tag 重拉(13 文件零差异)、`docs/dsh-contract.md` 与 compat-audit 头部版本字段同步,评估实证见 `docs/upgrade-assessments/dsh-0.1.7-alpha.2.md`。已知观察项(不阻塞):`dsh-tool-jobs` 唤醒次数上限默认放开(`maxConsecutiveWakes` 无默认值)使 P0-1 `agentBusy` 守卫的 idle 窗口概率上升,留实弹观察。
14
+
15
+ ## [2.4.1] - 2026-09-23
16
+
17
+ ### 修复
18
+
19
+ - **无快照的残骸仓库现在能被 gc 回收(issue #18)**:快照在 `git add` 与打 tag 之间失败会留下一批无 tag 可达的 blob,而 `.git/index` 仍逐条指着它们——`git prune`/`git gc --prune=now` 以 refs **+ 暂存 index** 为可达根,`git fsck --unreachable` 也报 0 个不可达(实测 5000 条 index + 0 ref 时输出 0 行),于是这类仓库一个对象都回收不掉,实测单工作区残留 78.4 MB 一根毛不减(POSIX 侧同结论)。修复=两套模板的 `gcScript` 在 `gc` 之前加条件块:`for-each-ref --count=1 refs` 为空**且** `ls-files` 非空(= 没有可回退快照的纯残骸)时先 `read-tree --empty` 清 index 再 gc。为什么条件必须两条:有 tag 时 index 是 diff/rollback 依赖的「当前清单」,绝不能清;而 index 本身是可重建缓存,snapshot/diff/rollback 三处开头都先 `add -A`。修复后实测同一残骸库 78.4 MB → 0(残留 1 个空树对象),有 tag 的仓库 gc 后 refs/index/tag 可解析性原样,win32 与 Git for Windows bash 双侧 6/6 通过。
20
+ - **posix oversize 的 find 括号写错,超大文件剔除在 Linux/macOS 上静默全废**:`find` 的 `(`/`)` 被写成 `"\("`/`"\)"` 放进数组元素——引号内是数据,bash 不剥反斜杠,find 收到两字符 token 直接报 `paths must precede expression` 退出 1;报错被 `2>/dev/null` 吞、管道零输出、xargs 空跑、`|| true` 兜底,整条剔除 100% 静默空转(比优化前更糟:连原有的慢扫都没有,且零报错)。修复=括号改单字符数据 `'('`/`')'`,并加外层 `-type d` 限定让 prune 只剪目录(否则与排除目录同名的大文件 `dist`/`target` 会被 `-name` 一并剪掉;pwsh 侧只对 `EnumerateDirectories` 判名,两侧同语义)。契约单测同步改钉修复后的形态。
21
+ - **pwsh `gcScript` 漏查 `$LASTEXITCODE`,gc 快速失败被误报成功**:pwsh 对原生命令非零退出不抛(EAP 不作用于 native),`git gc` 失败(磁盘满/锁冲突/杀软锁 pack)后脚本继续写 `gc.stamp`、输出 `GC_OK`、以 0 退出;`runShell` 只看进程退出码,于是 maintenance 把失败记成成功、推进完整 gcHours 周期——上条退避修复在 Windows(PS 5.1 与 pwsh 7 实测同结论)对快速失败完全失效,`gc.stamp` 这份跨重启的节流凭据也是假的。修复=gc 之后显式 `if ($LASTEXITCODE -ne 0) { throw ... }`(同 `rescueScript`/`diffScript` 既有写法);posix 版有 `set -e`,天然正确。契约单测新增「gc 失败必须可见」断言。
22
+ - **gc 失败后走退避重试,不再推进完整 gcHours 周期(快照仓库 GB 级膨胀的根因)**:实测一台重度机器上单工作区影子仓库积到 **3.35 GB loose 对象、in-pack 恒 0**——定期 gc 从未成功完成过。机制:`runGc` 失败(超时/磁盘/杀软)也把 `gcLastAt` 推进到此刻,等于失败一次就放弃整个 gcHours(默认 24h)周期;期间对象继续堆积,下一次 gc 只会更重、更容易超时,「失败→整天不重试→更重→更易失败」滚雪球,对象库永远压不进 pack。修复:失败只把 `gcLastAt` 回拨到「此刻 − gcHours + GC_RETRY_BACKOFF_MS(30 分钟)」,退避窗口后自动重试;成功维持原有「推进到此刻」语义;退避期间 gcCount 条数门槛照常生效,不会退化成「每条消息都重试」。`runGc` / `runGcAll` 两路径同修。新增 `tests/unit/maintenance-gc-backoff.test.js` 4 例钉住成功/失败/节流三条语义。
23
+ - **超大文件扫描(oversize)跳过被排除的大目录子树(两平台)**:原先 .NET 手动栈 / `find` 全工作树递归,`node_modules/`、`target/` 等已排除目录照样被逐文件 stat——一个 11943 个文件、10.96 GB 的 Rust `target/` 让每条消息的快照都多付一次全树扫描(I/O 风暴实测可拖垮宿主全部 HTTP 接口)。修复:把 exclude 表里 basename 形式(无通配、无内部斜杠、非 `!` 反选)的 pattern 按 gitignore 语义转为「任意层级同名目录整棵跳过」(pwsh 压栈过滤 HashSet;posix 转 `( -type d ( -name A -o -name B ) ) -prune -o` 前缀);复杂 pattern 不参与跳过、回退全扫该子树——多扫不漏检,fail-open 语义不变。
24
+ - **`package-layout` 单测兼容 npm 11+ 的 `pack --json` 输出形状**:新版输出 `{<包名>: {...}}`(旧版为数组 `[{...}]`),原解析取错层导致 `files` 恒空、两断言恒红。两种形状都取首个条目。
25
+
26
+ ### 变更
27
+
28
+ - **settings 旧面注册接线拆出为 `src/host/settings-bridge.ts`,并补上 CI 可跑的回归单测(防盲区)**:c3cc8a7(旧面注册 entry 先解 volatile ref)此前只有依赖本机 dsh 的 `verify:host` 与 M5-5 降级实弹能覆盖——它恰恰是从全部 M1–M4 门禁漏出、被实弹才抓到的缺陷类。接线本体(两代面分派 + installSettingsSection/installSection/register 三条旧面路径 + 新面 volatile 热更挂接)移入新模块并把 `dshSettings` 改为参数注入(模块不 import 私有 peer,生产路径不变:`index.ts` 裸导入后传入),`tests/unit/settings-bridge.test.js` 新增接线级 5 例:带 `{ get() }` ref 的假 config 走三条旧面注册路径时,收到的 entry(含 register 的 `base` 与卸载回退 source)必须是解过 ref 的普通值、新面挂 volatile-update 原样重读入口 config、接线抛错走 skip 记录。变异验证:临时还原回归形态(entry 不解 ref)三条路径测试全红。`index.ts` 有效行数 329 → 298(棘轮下降)。曾评估 verify-host 旧面 pass 直接喂 ref 形态 config 的替代方案——实测 cordis 的 config 校验入口先抛 `expected number but got object`(真实现场的 ref 化发生在 loader 校验之后的私有路径),无法在不假红的前提下复刻,故单测为该回归的唯一 CI 防线。
29
+ - **适配 dsh 0.1.7-alpha.1 的两处破坏性接缝(双分支共存,老版本行为不变)**:0.1.7 把插件依赖的两个接缝整体换代,本插件在同一次发布里同时支持旧线与新线——① **shell 执行接缝**:`ShellExecutor` 删除 `run`/`start` 抽象方法,改为 `resolve(request)` + `execute(spec)` → `ShellExecution.result()`;旧调用在 linux/darwin 上会抛 `shell.run is not a function`(建仓/快照/diff/回退/gc/索引读写全链失败、撤回不可用),win32 上则被方言探针的 catch 折成「误判 bash」走自建直连通道(功能侥幸可用但打误导日志)。修复=`runViaExecutor` 按**运行时方法探测**分流(`run` 优先 → `execute` 兜底),两分支共用同一 spec 构造与同一失败分级,并把 0.1.7 新增的 `exitCode === null`(准备期超时/信号终止)单独归类成「命令准备期超时」而不是不可读的 `exit null`。② **settings 接缝**:`dsh-settings` 导出面只剩 `SettingsForms`(`installSettingsSection`/`installSection`/`register` 全树移除),插件原三分支在 0.1.7 上**静默 no-op**(namespace 不注册、热更失效、设置卡片读不到覆盖字段、保存与恢复默认必失败);新面按 **profile 条目 id**(本机实测为 `recall`)经 `describe`/`update`/`replace` 读写,可写字段必须在 schema 上标 `.volatile()`(schemastery ≥3.18.3 才有,插件 feature-detect),热更经 loader 提交引用后派发的 `loader/volatile-update` 驱动,取值多一层 Volatile 解包。分流判据取「旧注册入口是否缺席」——只看 `describe`/`update` 是函数分不了流(旧面同样有),误判会让 0.1.6 上的卡片失联。探针按新实现重钉(shell 接缝、settings 面、volatile 热更与 `Fiber.entry` 形状),`verify:host` 桩升级为两个 pass(含「只给新面(无 `installSection`)也必须装配成功」——这正是本轮换代被自家桩掩盖的根因),compat-audit 新增 I38/I39,镜像与兼容声明同步到 0.1.7-alpha.1。
30
+ - **「立即 gc」起按磁盘枚举覆盖全部快照仓库,并回收空仓目录(issue #18)**:`runGcAll` 原先只遍历内存缓存(启动预热与历次操作认识过的工作区),会话已删、或从未在本进程 init 过的仓库在磁盘上有、内存里没有——那些恰好是 issue #18 的 GB 级残骸,此前完全不被 gc 覆盖。修复=注入 `dumpStores`(一条 shell 枚举容器子目录与降级候选目录)后取「内存 ∪ 磁盘」并按 git-dir 去重逐仓 gc;gc 之后追加**空仓目录回收**:仅当 index.json 明确解析为空数组、磁盘无 `snap-*` tag、内存无对应快照三条全中时,用 `legacyRmScript` 把整个 store 目录删掉(不可逆,删前 `console.error` 留痕;entries 为 null 即索引缺失/损坏的隔离现场一律不删)。实测(真实 store 目录、win32 pwsh):残骸仓库对象 202 → 1 后目录被删,有 tag 的仓库目录与 tag 原样。
31
+ - **工作区根自身是构建产物目录时不再建快照(issue #18)**:`baseExcludes` 是相对工作区 root 的 gitignore 模式,永远匹配不到 root 自己——在 `…/src-tauri/target/debug`、`dist` 这类目录里开会话时,整个构建产物目录会被全量快照(实测 7000 个产物条目里只有 root 下恰好同名的 `build/` 子树 2000 条被排除,root 整体照进),单会话即可积出 GB 级对象库,且这类目录没有回退价值。新增 `src/host/exclude-patterns.js` 判定「root 的任一路径段命中目录形态排除项」(win32 大小写不敏感、POSIX 敏感;看任一段而非 basename——最坏形态 `target/debug` 的 basename 是 `debug`,不在表里),命中的工作区在 `captureSnapshot` 于 `resolveStore` **之前**直接返回:不建快照、连空仓库都不建、不写 feedback(设计行为而非失败)。可见性:`init` 的 `notice.buildRootNotice` 与 `snapshot-info` 的 `notice` 下发同一句提示(含命中的路径段名、给出出口、≤140 字符),client 会话内说明一次并在近消息上节流 toast,避免「撤回按钮凭空不出现」。逃生口复用排除表本身(设置页删掉 `target/` 即恢复,无需新配置项)。存量残骸的整目录清理(含会话已删、内存里没有的仓库)留待后续按需实施。
32
+ - **`baseExcludes` 默认表加宽:编译产物目录与常见二进制/压缩包默认不进快照**:原先默认仅 `.git`、`node_modules/` 与两种存储目录名,`maxFileBytes` 只挡单个大文件、挡不住 `target/` 这类上万小文件整体 GB 级的构建产物——它们既拖慢每次快照的 `add`/遍历,也直接喂大对象库(上条实测的 3.35 GB loose 即由此而来)。新增 `target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/` 与 `*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso`。排除只作用于新增暂存(已有命中条目由 excludeSync 的清理循环在下次快照移出),仍可在设置页「基础排除表」逐条删改。
33
+ - **单次 gc 超时 10 分钟 → 30 分钟(`GC_TIMEOUT_MS`)**:GB 级 loose 对象库的单次 repack 可超 10 分钟,被杀的 gc 永远完不成;gc 与快照同在串行队列,加长只影响维护节奏。
34
+
5
35
  ## [2.3.24] - 2026-09-18
6
36
 
7
37
  ### 新增
package/README.en.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)
8
8
  ![Build](https://img.shields.io/badge/pure%20JS-green)
9
9
 
10
- [![DSH](https://img.shields.io/badge/DSH-0.1.6--alpha.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.2)
10
+ [![DSH](https://img.shields.io/badge/DSH-0.1.7--alpha.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-alpha.2)
11
11
  ![DSH](https://img.shields.io/badge/DSH-Desktop-blue)
12
12
  ---
13
13
  Under any message you've sent, click "↶ Recall" — **your workspace files and the conversation history roll back to just before that message was sent**.
@@ -45,7 +45,7 @@ Files and conversation roll back together: the workspace is first snapshotted in
45
45
 
46
46
  Abilities with a version in parentheses require that version or later; the rest have no special version requirement.
47
47
 
48
- - **Files + conversation, rolled back together**: recalling isn't just about chat history — files the agent modified go back to their original state too; immune to your project's `.gitattributes` conversion, with byte-level fidelity for line endings and binary content (2.1.1+).
48
+ - **Files + conversation, rolled back together**: recalling isn't just about chat history — files the agent modified go back to their original state too; immune to your project's `.gitattributes` conversion, with byte-level fidelity for line endings and binary content (2.1.1+).
49
49
  - **Just want a fresh conversation? Leave the files alone** (2.3.24+): the confirmation panel offers a recall-scope choice — the default "Roll back files & conversation" behaves as before; picking "Conversation only" keeps your project files byte-for-byte untouched (no safety snapshot either) and rewinds only the conversation, ideal when you dislike the reply but want to keep the file changes.
50
50
  - **Never touches your project's own git, and keeps it clean**: snapshots live in an independent shadow git repository — branches, staging area, and uncommitted changes are untouched; storage stays under `$DSH_HOME` regardless of the session's sandbox permission (workspace-write / read-only sessions work as usual), falling back to an in-project `.dsh-recall-snapshots` only when home itself is unwritable.
51
51
  - **See the list before you act — and change your mind as often as you like**: recall first shows the list of files that will change (modified / restored / deleted); nothing runs until you confirm. After a recall you can recall again to an even earlier point, and files overwritten during a recall always remain recoverable (up to 500 snapshots per workspace by default).
@@ -69,7 +69,7 @@ Boundaries accepted by design and edge cases not yet covered — worth checking
69
69
 
70
70
  ## Installation
71
71
 
72
- Prerequisites: git CLI (without it the recall button won't appear and a notice shows at the top of the page — DSH itself keeps running); PowerShell 5.1 / 7 on Windows, bash + git on Linux/macOS; DSH `0.1.2-alpha.1` through `0.1.6-alpha.2` (peerDependencies open a window per minor line: `>=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7`, consistent with the `dsh.compatibility.dshReleases` declaration; each line is anchored at its first verified version with an exclusive upper bound at the next minor, so later prereleases/releases within a verified line are admitted without touching the peer declaration, while unverified new minor lines (e.g. 0.1.7) remain blocked). `0.1.1-rc.2` and earlier are no longer declared supported: their client runtime lacks the `sessions`/`workspaces`/`uiWorkspace` services, so the plugin UI silently fails to render.
72
+ Prerequisites: git CLI (without it the recall button won't appear and a notice shows at the top of the page — DSH itself keeps running); PowerShell 5.1 / 7 on Windows, bash + git on Linux/macOS; DSH `0.1.2-alpha.1` through `0.1.7-alpha.2` (peerDependencies open a window per minor line: `>=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8`, consistent with the `dsh.compatibility.dshReleases` declaration; each line is anchored at its first verified version with an exclusive upper bound at the next minor, so later prereleases/releases within a verified line are admitted without touching the peer declaration, while unverified new minor lines remain blocked). **0.1.7-alpha.1 is a breaking release** (`ShellExecutor.run`/`start` became `execute().result()`, and the settings surface became `SettingsForms`, addressed by profile entry id with `.volatile()` marked fields), and the plugin ships **both seams side by side**: the same release keeps working on every 0.1.2–0.1.6 line and on 0.1.7, with unchanged behaviour on older DSH versions. `0.1.1-rc.2` and earlier are no longer declared supported: their client runtime lacks the `sessions`/`workspaces`/`uiWorkspace` services, so the plugin UI silently fails to render.
73
73
 
74
74
  - Official DSH plugin command: install and auto-mount into the web profile
75
75
  ```powershell
@@ -108,7 +108,7 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
108
108
  | `maxFileBytes` | 104857600 (100MB) | Files larger than this are neither snapshotted nor touched by recalls |
109
109
  | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
110
110
  | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
111
- | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/` | Base exclusion list (gitignore syntax, lower priority than exclude.txt) |
111
+ | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/`, `target/`, `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.nuxt/`, `.output/`, `.cache/`, `.gradle/`, `*.exe`, `*.dll`, `*.pdb`, `*.so`, `*.dylib`, `*.msi`, `*.zip`, `*.7z`, `*.rar`, `*.tar`, `*.tar.gz`, `*.iso` | Base exclusion list (gitignore syntax, lower priority than exclude.txt); excluded large directories are skipped as whole subtrees during snapshot scans. Directory-form entries (e.g. `target/`) mean one more thing: **when a path segment of the workspace root itself matches, snapshots are disabled for that workspace** — patterns are relative to the root, so they can never exclude the root itself (i.e. a session opened inside a build-output directory), and such directories have no rollback value anyway; remove the entry to re-enable |
112
112
  | `refillDraft` | true | Refill the recalled message (text and attachments) into the input box after a recall |
113
113
  | `snapshotEnabled` | true | Master snapshot switch (off = no new snapshots; existing snapshots remain recallable) |
114
114
  | `archiveOriginal` | true | Archive the original session after a recall (off = the original session stays in the session list) |
@@ -165,9 +165,9 @@ Note: all source lives in `src/` (host in `src/host/`, browser side in `src/clie
165
165
 
166
166
  ### Tests
167
167
 
168
- - `npm test`: pure-logic unit tests (vitest, 28 files / 361 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, script-template same-name-export contract, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
169
- - `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, and goes red on violation;
170
- - `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context, asserting inject declarations, endpoint registration, Config schema, and teardown cleanliness, catching assembly regressions before release;
168
+ - `npm test`: pure-logic unit tests (vitest, 34 files / 425 cases, no DSH dependency, runs identically in CI and locally) — config parsing, snapshot parsers, rescue orchestration, error classification, script-template same-name-export contract, shell execution dual-channel split and failure grading, settings bridge across both generations, client pure functions, published-package layout, snapshot index persistence, storage caps and retention, etc.;
169
+ - `npm run test:probe`: official-API field probes (requires a local dsh installation; **must run after any dsh upgrade**) — pins fields like `renderMessageImages`/`node`/`cwd`, `atSeq`/`increaseTitle` and the fork-cut anchors of `sessions.fork`, `listSessions` record shape, `AgentRegistry`, the shell execution seam (`execute`/`ShellExecution.result`), the settings surface (`SettingsForms`/profile entry id/volatile gate) plus `loader/volatile-update` and the `Fiber.entry` shape, and goes red on violation;
170
+ - `npm run verify:host`: assembly gate (requires a local dsh installation) — boots the plugin with a real cordis context in two passes (legacy settings stub and a modern-face-only stub), asserting inject declarations, endpoint registration, Config schema, teardown cleanliness and settings-face dispatch, catching assembly regressions before release;
171
171
  - `npm run build`: full host+client build (mandatory after any `src/` change); `npm run check:dsh`: dsh version inspection (pre-release).
172
172
  - CI (GitHub Actions) runs `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + unified artifact-freshness check (`npm run build && git diff --exit-code lib/`; probes and the assembly gate only run on machines with dsh).
173
173
 
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-recall-plugin?style=flat-square&label=npm&color=3178C6)](https://www.npmjs.com/package/dsh-recall-plugin)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/dsh-recall-plugin?style=flat-square&label=downloads&color=1F883D)](https://www.npmjs.com/package/dsh-recall-plugin)
7
7
  ![License](https://img.shields.io/badge/license-MIT-blue)
8
- [![DSH](https://img.shields.io/badge/DSH-0.1.6--alpha.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.6-alpha.2)
8
+ [![DSH](https://img.shields.io/badge/DSH-0.1.7--alpha.2-blue)](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-alpha.2)
9
9
  ---
10
10
  在任意一条你发过的消息下方点「↶ 撤回」——**工作区文件和对话历史一起回到那条消息发出之前的状态**。
11
11
  ---
@@ -43,7 +43,7 @@
43
43
 
44
44
  带括号版本号的能力需要该版本或更高;其余无特殊版本要求。
45
45
 
46
- - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样;不受项目 `.gitattributes` 转换影响,换行与二进制内容字节级保真(2.1.1+)。
46
+ - **文件 + 对话,整段回退**:撤回的不只是聊天记录,agent 改过的文件也一并回到原样;不受项目 `.gitattributes` 转换影响,换行与二进制内容字节级保真(2.1.1+)。
47
47
  - **只想重来对话?文件可以不动**(2.3.24+):确认面板里可二选一撤回范围——默认「回退文件与对话」同现状;选「仅撤回对话」则项目文件一个字节都不动(也不打安全快照),只有对话回退,适合对生成结果不满意、但文件改动恰是想要的场景。
48
48
  - **不碰你项目自己的 git,目录保持干净**:快照存在独立的影子 git 仓库里,分支、暂存区、未提交改动统统不受影响;存储默认落在 `$DSH_HOME` 下,与会话的沙箱权限无关(workspace-write / read-only 照常工作),仅当 home 不可写才降级到项目内 `.dsh-recall-snapshots`。
49
49
  - **先看清单再动手,可反复后悔**:撤回前展示将变更的文件清单(修改 / 恢复 / 删除),确认后才执行;撤回后还能再撤到更早,被覆盖的文件一直找得回来(默认每工作区保留 500 条)。
@@ -67,7 +67,7 @@
67
67
 
68
68
  ## 安装
69
69
 
70
- 前置:git CLI(未装时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);Windows 上 PowerShell 5.1 / 7 均可,Linux/macOS 需 bash + git;DSH `0.1.2-alpha.1` 至 `0.1.6-alpha.2`(peerDependencies 为按 minor 版本线开窗 `>=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7`,与 `dsh.compatibility.dshReleases` 声明一致;每条线以首个核验版本为下限、开一条上界到下一 minor 的区间,同线内后续 prerelease/正式版自动放行、无需改 peer 声明,未验证的新 minor 线(如 0.1.7)仍被拦截)。`0.1.1-rc.2` 及更早不再声明支持:那条线的客户端运行时没有 `sessions`/`workspaces`/`uiWorkspace` 服务,插件 UI 会静默不渲染。
70
+ 前置:git CLI(未装时撤回按钮不出现,页面顶部会提示安装 git,不影响 DSH 运行);Windows 上 PowerShell 5.1 / 7 均可,Linux/macOS 需 bash + git;DSH `0.1.2-alpha.1` 至 `0.1.7-alpha.2`(peerDependencies 为按 minor 版本线开窗 `>=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8`,与 `dsh.compatibility.dshReleases` 声明一致;每条线以首个核验版本为下限、开一条上界到下一 minor 的区间,同线内后续 prerelease/正式版自动放行、无需改 peer 声明,未核验的新 minor 线仍被拦截)。**0.1.7-alpha.1 是破坏性版本**(`ShellExecutor` 的 `run`/`start` 换成 `execute().result()`;settings 面换成 `SettingsForms`、按 profile 条目 id 寻址并要求字段标 `.volatile()`),插件已做**双分支共存适配**——同一次发布同时兼容 0.1.2–0.1.6 各线段与 0.1.7,老版本 DSH 上的行为不变。`0.1.1-rc.2` 及更早不再声明支持:那条线的客户端运行时没有 `sessions`/`workspaces`/`uiWorkspace` 服务,插件 UI 会静默不渲染。
71
71
 
72
72
 
73
73
  - DSH 官方插件命令:安装并自动挂载进 web profile
@@ -111,7 +111,7 @@ dsh plugin --profile web remove dsh-recall-plugin
111
111
  | `maxFileBytes` | 104857600(100MB) | 超过该大小的文件不进快照、不被回退触碰 |
112
112
  | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
113
113
  | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
114
- | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/` | 基础排除表(gitignore 语法,优先级低于 exclude.txt) |
114
+ | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
115
115
  | `refillDraft` | true | 撤回后把被撤回的消息(文本与附件)回填到输入框 |
116
116
  | `snapshotEnabled` | true | 快照总开关(关闭只冻结新建,已有快照仍可撤回) |
117
117
  | `archiveOriginal` | true | 撤回后归档原会话(关闭后原会话保留在会话列表中) |
@@ -168,9 +168,9 @@ pnpm install
168
168
 
169
169
  ### 测试
170
170
 
171
- - `npm test`:纯逻辑单测(vitest,28 个文件 361 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、脚本模板同名导出契约、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
172
- - `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle`、`listSessions` 记录结构、`AgentRegistry` 等字段,违反即红;
173
- - `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件,断言 inject 声明、端点注册、Config schema、卸载清理,装配回归发版前即可拦截;
171
+ - `npm test`:纯逻辑单测(vitest,34 个文件 425 例,无 DSH 依赖,CI 与本地同跑)——配置解析、快照解析器、救援编排、错误分类、脚本模板同名导出契约、执行通道双分支与失败分级、settings 双代桥接、客户端纯函数、发布包内容布局、快照索引持久化、存储上限与保留天数等;
172
+ - `npm run test:probe`:官方 API 字段探针(依赖本机 dsh 安装;dsh 升级后本地必跑)——钉住 `renderMessageImages`/`node`/`cwd`、`sessions.fork` 的 `atSeq`/`increaseTitle` 与切点锚点、`listSessions` 记录结构、`AgentRegistry`、shell 执行接缝(`execute`/`ShellExecution.result`)、settings 面(`SettingsForms`/profile 条目 id/volatile 门槛)与 `loader/volatile-update`/`Fiber.entry` 形状等字段,违反即红;
173
+ - `npm run verify:host`:装配门禁(依赖本机 dsh 安装)——用真实 cordis 起插件跑两个 pass(旧面桩 + 只给新面的桩),断言 inject 声明、端点注册、Config schema、卸载清理与 settings 面分派,装配回归发版前即可拦截;
174
174
  - `npm run build`:host+client 全量打包(改任何 `src/` 后必跑);`npm run check:dsh`:dsh 版本巡检(发布前)。
175
175
  - CI(GitHub Actions)跑 `npm ci --legacy-peer-deps` + `npm run typecheck` + `npm test` + 产物新鲜度统一校验(`npm run build && git diff --exit-code lib/`;探针与装配门禁只在有 dsh 的机器跑)。
176
176
 
package/cordis.patch.yml CHANGED
@@ -17,4 +17,7 @@
17
17
  gcHours: 24 # 距上次 gc 超过多少小时触发(与条数先到先触发)
18
18
  maxFileBytes: 104857600 # 超过该大小的文件不进快照、不被回退触碰
19
19
  refillDraft: true # 撤回后把被撤回的消息文本回填到输入框
20
- # baseExcludes: ['.git', 'node_modules/', '.dsh-recall-snapshots/'] # 基础排除表(gitignore 语法)
20
+ # baseExcludes 默认不在此列出:默认表随插件 schema 下发,在设置页
21
+ # 「基础排除表」增删即可。若要在这里覆盖,必须重述完整表——patch 按行
22
+ # 替换 config,写半张表会取消其余默认排除(如漏掉 dsh-recall-snapshots/
23
+ # 会在 root=HOME 时把快照库自身吞进快照)。
package/lib/client.js CHANGED
@@ -362,6 +362,9 @@
362
362
  if (notice && notice.homeFallback) {
363
363
  showNotice("home", "home \u76EE\u5F55\u4E0D\u53EF\u5199\uFF0C\u5FEB\u7167\u5DF2\u964D\u7EA7\u5B58\u50A8\u5230\u9879\u76EE\u5185 .dsh-recall-snapshots \u76EE\u5F55\u3002");
364
364
  }
365
+ if (notice && notice.buildRootNotice) {
366
+ showNotice("buildRoot", String(notice.buildRootNotice).slice(0, 140));
367
+ }
365
368
  }).catch(() => {
366
369
  initMap.delete(sessionId);
367
370
  });
@@ -759,6 +762,10 @@
759
762
  if (recent) showThrottledToast("\u5FEB\u7167\u5931\u8D25\uFF1A" + String(res.error || "\u672A\u77E5\u539F\u56E0").slice(0, 140));
760
763
  return;
761
764
  }
765
+ if (res && res.notice) {
766
+ if (recent) showThrottledToast(String(res.notice).slice(0, 140));
767
+ return;
768
+ }
762
769
  if (recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS);
763
770
  }).catch(() => {
764
771
  if (alive && recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS);
package/lib/config.js CHANGED
@@ -1,20 +1,115 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
+ const BASE_EXCLUDES = [
3
+ ".git",
4
+ "node_modules/",
5
+ ".dsh-recall-snapshots/",
6
+ "dsh-recall-snapshots/",
7
+ "target/",
8
+ "dist/",
9
+ "build/",
10
+ "out/",
11
+ "coverage/",
12
+ ".next/",
13
+ ".nuxt/",
14
+ ".output/",
15
+ ".cache/",
16
+ ".gradle/",
17
+ "*.exe",
18
+ "*.dll",
19
+ "*.pdb",
20
+ "*.so",
21
+ "*.dylib",
22
+ "*.msi",
23
+ "*.zip",
24
+ "*.7z",
25
+ "*.rar",
26
+ "*.tar",
27
+ "*.tar.gz",
28
+ "*.iso"
29
+ ];
30
+ function withVolatile(field) {
31
+ const schema = field;
32
+ return typeof schema.volatile === "function" ? schema.volatile() : field;
33
+ }
2
34
  const Config = Schema.object({
3
- gcSnaps: Schema.number().default(50).description("\u6BCF\u79EF\u7D2F\u591A\u5C11\u6761\u5FEB\u7167\u89E6\u53D1\u4E00\u6B21 git gc"),
4
- gcHours: Schema.number().default(24).description("\u8DDD\u4E0A\u6B21 gc \u8D85\u8FC7\u591A\u5C11\u5C0F\u65F6\u89E6\u53D1\uFF08\u4E0E\u6761\u6570\u5148\u5230\u5148\u89E6\u53D1\uFF09"),
5
- maxFileBytes: Schema.number().default(104857600).description("\u8D85\u8FC7\u8BE5\u5B57\u8282\u6570\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0"),
6
- maxSnapshotsPerWorkspace: Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684"),
7
- // 排除表必须同时覆盖两种存储目录名:降级存储是项目内 .dsh-recall-snapshots/,
8
- // 而 home 存储目录名是 dsh-recall-snapshots/(无点)——工作区 root 恰为
9
- // HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
10
- // 把影子仓库自己吞进去、快照全部失败(issue #6)
11
- baseExcludes: Schema.array(Schema.string()).default([".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"]).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09"),
12
- refillDraft: Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846"),
13
- snapshotEnabled: Schema.boolean().default(true).description("\u542F\u7528\u6D88\u606F\u5FEB\u7167\uFF08\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\uFF0C\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE\uFF09"),
14
- archiveOriginal: Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u5F52\u6863\u539F\u4F1A\u8BDD\uFF08\u5173\u95ED\u540E\u539F\u4F1A\u8BDD\u4FDD\u7559\u5728\u5217\u8868\u4E2D\uFF09"),
15
- retentionDays: Schema.number().default(0).description("\u6309\u5929\u6570\u4FDD\u7559\u5FEB\u7167\uFF0C\u8D85\u671F\u81EA\u52A8\u5220\u9664\uFF1B0 \u8868\u793A\u4E0D\u542F\u7528")
35
+ gcSnaps: withVolatile(Schema.number().default(50).description("\u6BCF\u79EF\u7D2F\u591A\u5C11\u6761\u5FEB\u7167\u89E6\u53D1\u4E00\u6B21 git gc")),
36
+ gcHours: withVolatile(Schema.number().default(24).description("\u8DDD\u4E0A\u6B21 gc \u8D85\u8FC7\u591A\u5C11\u5C0F\u65F6\u89E6\u53D1\uFF08\u4E0E\u6761\u6570\u5148\u5230\u5148\u89E6\u53D1\uFF09")),
37
+ maxFileBytes: withVolatile(Schema.number().default(104857600).description("\u8D85\u8FC7\u8BE5\u5B57\u8282\u6570\u7684\u6587\u4EF6\u4E0D\u8FDB\u5FEB\u7167\u3001\u4E0D\u88AB\u56DE\u9000\u89E6\u78B0")),
38
+ maxSnapshotsPerWorkspace: withVolatile(Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684")),
39
+ baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
40
+ refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
41
+ snapshotEnabled: withVolatile(Schema.boolean().default(true).description("\u542F\u7528\u6D88\u606F\u5FEB\u7167\uFF08\u5173\u95ED\u540E\u4E0D\u518D\u65B0\u5EFA\uFF0C\u5DF2\u6709\u5FEB\u7167\u4ECD\u53EF\u64A4\u56DE\uFF09")),
42
+ archiveOriginal: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u5F52\u6863\u539F\u4F1A\u8BDD\uFF08\u5173\u95ED\u540E\u539F\u4F1A\u8BDD\u4FDD\u7559\u5728\u5217\u8868\u4E2D\uFF09")),
43
+ retentionDays: withVolatile(Schema.number().default(0).description("\u6309\u5929\u6570\u4FDD\u7559\u5FEB\u7167\uFF0C\u8D85\u671F\u81EA\u52A8\u5220\u9664\uFF1B0 \u8868\u793A\u4E0D\u542F\u7528"))
16
44
  });
17
- const BASE_EXCLUDES = [".git", "node_modules/", ".dsh-recall-snapshots/", "dsh-recall-snapshots/"];
45
+ function isLegacySettingsFace(settings) {
46
+ const svc = settings;
47
+ if (!svc) return false;
48
+ return typeof svc.installSection === "function" || typeof svc.register === "function";
49
+ }
50
+ const LEGACY_SETTINGS_NS = "dsh-recall";
51
+ function resolveSettingsNs(ctx, settings) {
52
+ const entry = ctx && ctx.fiber ? ctx.fiber.entry : null;
53
+ const entryIds = [];
54
+ for (const candidate of [entry && entry.options ? entry.options.id : null, entry ? entry.id : null]) {
55
+ if (typeof candidate === "string" && candidate && entryIds.indexOf(candidate) < 0) entryIds.push(candidate);
56
+ }
57
+ const known = describeNamespaces(settings);
58
+ if (isLegacySettingsFace(settings)) {
59
+ if (known.indexOf(LEGACY_SETTINGS_NS) >= 0) return LEGACY_SETTINGS_NS;
60
+ for (const id of entryIds) {
61
+ if (known.indexOf(id) >= 0) return id;
62
+ }
63
+ return LEGACY_SETTINGS_NS;
64
+ }
65
+ for (const id of entryIds) {
66
+ if (known.indexOf(id) >= 0) return id;
67
+ }
68
+ return entryIds.length ? entryIds[0] : null;
69
+ }
70
+ function describeNamespaces(settings) {
71
+ const svc = settings;
72
+ if (!svc || typeof svc.describe !== "function") return [];
73
+ try {
74
+ const list = svc.describe();
75
+ const out = [];
76
+ for (const row of Array.isArray(list) ? list : []) {
77
+ const ns = row && row.ns;
78
+ if (typeof ns === "string" && ns) out.push(ns);
79
+ }
80
+ return out;
81
+ } catch (error) {
82
+ return [];
83
+ }
84
+ }
85
+ function unwrapConfig(raw) {
86
+ const source = raw && typeof raw === "object" ? raw : {};
87
+ const out = {};
88
+ for (const key of Object.keys(source)) out[key] = unwrapOnce(source[key]);
89
+ return out;
90
+ }
91
+ function unwrapOnce(value) {
92
+ if (isVolatileRef(value)) return value.get();
93
+ if (Array.isArray(value)) return value.map((item) => isVolatileRef(item) ? item.get() : item);
94
+ if (isPlainObject(value)) {
95
+ const out = {};
96
+ const entries = value;
97
+ for (const key of Object.keys(entries)) {
98
+ const item = entries[key];
99
+ out[key] = isVolatileRef(item) ? item.get() : item;
100
+ }
101
+ return out;
102
+ }
103
+ return value;
104
+ }
105
+ function isVolatileRef(value) {
106
+ return Boolean(value) && typeof value.get === "function";
107
+ }
108
+ function isPlainObject(value) {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
110
+ const proto = Object.getPrototypeOf(value);
111
+ return proto === Object.prototype || proto === null;
112
+ }
18
113
  const DEFAULTS = {
19
114
  gcSnaps: 50,
20
115
  gcHours: 24,
@@ -49,5 +144,10 @@ function createConfig(raw) {
49
144
  export {
50
145
  Config,
51
146
  DEFAULTS,
52
- createConfig
147
+ LEGACY_SETTINGS_NS,
148
+ createConfig,
149
+ isLegacySettingsFace,
150
+ resolveSettingsNs,
151
+ unwrapConfig,
152
+ withVolatile
53
153
  };
@@ -0,0 +1,34 @@
1
+ const NON_NAME_CHARS = /[*?[\]\\/]/;
2
+ function dirNamePatterns(base) {
3
+ if (!Array.isArray(base)) return [];
4
+ const out = [];
5
+ for (const raw of base) {
6
+ if (typeof raw !== "string") continue;
7
+ const t = raw.trim();
8
+ if (!t || t.charAt(0) === "#" || t.charAt(0) === "!") continue;
9
+ const name = t.replace(/\/+$/, "");
10
+ if (!name || NON_NAME_CHARS.test(name)) continue;
11
+ out.push(name);
12
+ }
13
+ return out;
14
+ }
15
+ function buildArtifactRootSegment(root, base, isWin) {
16
+ const names = dirNamePatterns(base);
17
+ if (!names.length || typeof root !== "string" || !root) return null;
18
+ const parts = root.split(/[\\/]+/).filter((s) => s.length > 0);
19
+ const segs = isWin && /^[a-zA-Z]:$/.test(parts[0] || "") ? parts.slice(1) : parts;
20
+ const pool = isWin ? names.map((n) => n.toLowerCase()) : names;
21
+ for (const seg of segs) {
22
+ const i = pool.indexOf(isWin ? seg.toLowerCase() : seg);
23
+ if (i >= 0) return names[i];
24
+ }
25
+ return null;
26
+ }
27
+ function buildRootNotice(segment) {
28
+ return "\u5F53\u524D\u5DE5\u4F5C\u533A\u4F4D\u4E8E\u6784\u5EFA\u4EA7\u7269\u76EE\u5F55\uFF08\u8DEF\u5F84\u6BB5 " + segment + "\uFF09\uFF0C\u5DF2\u8DF3\u8FC7\u9879\u76EE\u5FEB\u7167\uFF1B\u5982\u9700\u5728\u6B64\u76EE\u5F55\u4F7F\u7528\u64A4\u56DE\uFF0C\u8BF7\u5728\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u4ECE\u300C\u57FA\u7840\u6392\u9664\u8868\u300D\u79FB\u9664\u8BE5\u9879\u3002";
29
+ }
30
+ export {
31
+ buildArtifactRootSegment,
32
+ buildRootNotice,
33
+ dirNamePatterns
34
+ };
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createConfig, Config, DEFAULTS } from "./config.js";
1
+ import { createConfig, Config, DEFAULTS, unwrapConfig } from "./config.js";
2
+ import { installSettingsNamespace } from "./settings-bridge.js";
2
3
  import { parseStoresDump, parseExcludeDump } from "./dump-parse.js";
3
4
  import { createRuntime } from "./store.js";
4
5
  import { createSnapshots, rescueRollback } from "./snapshots.js";
@@ -11,14 +12,14 @@ import * as E from "./errors.js";
11
12
  const name = "dsh-recall-plugin";
12
13
  const inject = ["shell", "sessions", "agents"];
13
14
  function apply(ctx, config) {
14
- const cfg = createConfig(config);
15
+ const cfg = createConfig(unwrapConfig(config));
15
16
  const rt = createRuntime(ctx, cfg);
16
17
  const snaps = createSnapshots(ctx, rt, cfg);
17
- const maint = createMaintenance(ctx, rt, snaps, cfg);
18
+ const maint = createMaintenance(ctx, rt, snaps, cfg, { dumpStores });
18
19
  const state = rt.state;
19
20
  let readSettings = () => config;
20
21
  function applyResolvedConfig(resolved) {
21
- Object.assign(cfg, createConfig(resolved && typeof resolved === "object" ? resolved : {}));
22
+ Object.assign(cfg, createConfig(unwrapConfig(resolved)));
22
23
  }
23
24
  const settingsHooks = {
24
25
  setSource: (fn) => {
@@ -26,29 +27,14 @@ function apply(ctx, config) {
26
27
  },
27
28
  onChange: () => applyResolvedConfig(readSettings())
28
29
  };
29
- try {
30
- if (typeof dshSettings.installSettingsSection === "function") {
31
- dshSettings.installSettingsSection(ctx, "dsh-recall", Config, config, settingsHooks);
32
- } else if (typeof ctx.inject === "function") {
33
- ctx.inject(["settings"], (settingsCtx) => {
34
- const settingsService = settingsCtx.settings;
35
- if (typeof settingsService.installSection === "function") {
36
- settingsService.installSection(ctx, "dsh-recall", Config, config, settingsHooks);
37
- } else if (typeof settingsService.register === "function") {
38
- const scope = settingsService.register("dsh-recall", Config, { base: config });
39
- settingsHooks.setSource(() => scope.get());
40
- settingsHooks.onChange();
41
- scope.watch(() => settingsHooks.onChange());
42
- settingsCtx.effect(() => () => {
43
- settingsHooks.setSource(() => config);
44
- settingsHooks.onChange();
45
- });
46
- }
47
- });
48
- }
49
- } catch (error) {
50
- rt.recordError("recall settings namespace skipped: " + String(error));
51
- }
30
+ installSettingsNamespace({
31
+ ctx,
32
+ dshSettings,
33
+ config,
34
+ settingsHooks,
35
+ applyResolvedConfig,
36
+ recordError: (message) => rt.recordError(message)
37
+ });
52
38
  const supported = process.platform === "win32" || process.platform === "linux" || process.platform === "darwin";
53
39
  const MAX_BODY_BYTES = 1048576;
54
40
  const listCache = { at: 0, items: null, stale: false, refreshing: null };
@@ -1,3 +1,5 @@
1
+ const GC_RETRY_BACKOFF_MS = 18e5;
2
+ const GC_TIMEOUT_MS = 18e5;
1
3
  function selectOverLimitVictims(snapshots, limit) {
2
4
  if (!limit || limit <= 0) return /* @__PURE__ */ new Map();
3
5
  const byRoot = /* @__PURE__ */ new Map();
@@ -27,7 +29,7 @@ function selectExpiredVictims(snapshots, retentionDays, now) {
27
29
  }
28
30
  return byRoot;
29
31
  }
30
- function createMaintenance(ctx, rt, snaps, config) {
32
+ function createMaintenance(ctx, rt, snaps, config, deps = {}) {
31
33
  const sessions = ctx.sessions;
32
34
  const state = rt.state;
33
35
  const S = rt.scripts;
@@ -150,20 +152,80 @@ function createMaintenance(ctx, rt, snaps, config) {
150
152
  state.gcCount.set(store.git, count);
151
153
  if (!force && count < config.gcSnaps && now - last < config.gcHours * 36e5) return false;
152
154
  state.gcCount.set(store.git, 0);
155
+ let ok = false;
153
156
  try {
154
157
  await sweepDeletedSessions();
155
158
  await enforceLimits();
156
159
  await enforceRetention();
157
- await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
160
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: GC_TIMEOUT_MS, stdoutMaxBytes: 4096 });
161
+ ok = true;
158
162
  } catch (error) {
159
163
  rt.recordError("recall maintenance failed: " + String(error));
160
164
  }
161
- state.gcLastAt.set(store.git, Date.now());
165
+ state.gcLastAt.set(store.git, ok ? Date.now() : Date.now() - config.gcHours * 36e5 + GC_RETRY_BACKOFF_MS);
162
166
  return true;
163
167
  }
168
+ async function dumpDiskStores() {
169
+ if (typeof deps.dumpStores !== "function") return /* @__PURE__ */ new Map();
170
+ try {
171
+ return await deps.dumpStores();
172
+ } catch (error) {
173
+ rt.recordError("recall disk store dump failed: " + String(error));
174
+ return /* @__PURE__ */ new Map();
175
+ }
176
+ }
177
+ function mergeStores(disk) {
178
+ const byGit = /* @__PURE__ */ new Map();
179
+ for (const store of state.stores.values()) {
180
+ if (store && store.git) byGit.set(store.git, store);
181
+ }
182
+ for (const dir of disk.keys()) {
183
+ if (!dir) continue;
184
+ const store = rt.storeFromDir(dir, false);
185
+ if (store && store.git && !byGit.has(store.git)) byGit.set(store.git, store);
186
+ }
187
+ return Array.from(byGit.values());
188
+ }
189
+ function memorySnapshotCount(dir, root) {
190
+ let n = 0;
191
+ for (const snap of state.snapshots.values()) {
192
+ if (!snap) continue;
193
+ const owner = state.stores.get(snap.root);
194
+ if (owner && owner.dir === dir || root && snap.root === root) n++;
195
+ }
196
+ return n;
197
+ }
198
+ async function reapEmptyStores(disk) {
199
+ if (!state.gitExe) return 0;
200
+ let removed = 0;
201
+ for (const [dir, info] of disk) {
202
+ if (!dir || !info) continue;
203
+ if (!Array.isArray(info.entries) || info.entries.length) continue;
204
+ if (memorySnapshotCount(dir, info.root) > 0) continue;
205
+ const store = rt.storeFromDir(dir, false);
206
+ try {
207
+ const tags = S.stripBom(await rt.runShell(S.listTagsScript(store, state.gitExe), { timeoutMs: 6e4, stdoutMaxBytes: 4096 }));
208
+ if (String(tags).trim()) continue;
209
+ await rt.runShell(S.legacyRmScript(dir), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
210
+ for (const [root, st] of state.stores.entries()) {
211
+ if (st && st.dir === dir) state.stores.delete(root);
212
+ }
213
+ state.gcLastAt.delete(store.git);
214
+ state.gcCount.delete(store.git);
215
+ if (info.root) state.indexLoaded.delete(info.root);
216
+ console.error("recall reaped empty store:", dir);
217
+ removed++;
218
+ } catch (error) {
219
+ rt.recordError("recall reap store failed for " + dir + ": " + String(error));
220
+ }
221
+ }
222
+ return removed;
223
+ }
164
224
  async function runGcAll() {
165
- const stores = Array.from(new Set(Array.from(state.stores.values()).filter((s) => Boolean(s))));
166
- if (!stores.length || !state.gitExe) return false;
225
+ if (!state.gitExe) return false;
226
+ const disk = await dumpDiskStores();
227
+ const stores = mergeStores(disk);
228
+ if (!stores.length) return false;
167
229
  try {
168
230
  await sweepDeletedSessions();
169
231
  } catch (error) {
@@ -182,14 +244,16 @@ function createMaintenance(ctx, rt, snaps, config) {
182
244
  let done = 0;
183
245
  for (const store of stores) {
184
246
  try {
185
- await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
247
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: GC_TIMEOUT_MS, stdoutMaxBytes: 4096 });
186
248
  done++;
249
+ state.gcLastAt.set(store.git, Date.now());
187
250
  } catch (error) {
188
251
  rt.recordError("recall gc failed for " + (store && store.git) + ": " + String(error));
252
+ state.gcLastAt.set(store.git, Date.now() - config.gcHours * 36e5 + GC_RETRY_BACKOFF_MS);
189
253
  }
190
- state.gcLastAt.set(store.git, Date.now());
191
254
  state.gcCount.set(store.git, 0);
192
255
  }
256
+ await reapEmptyStores(disk);
193
257
  return true;
194
258
  }
195
259
  async function maybeMaintain(sessionId) {
@@ -1,5 +1,6 @@
1
1
  import { ENV_HINTS } from "./diagnostics.js";
2
2
  import { parseTreeId } from "./snapshots.js";
3
+ import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js";
3
4
  function createRoutesCore(deps) {
4
5
  const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, E } = deps;
5
6
  return {
@@ -21,6 +22,8 @@ function createRoutesCore(deps) {
21
22
  gitMissing: state.gitExe === "",
22
23
  homeFallback: store ? !store.home : false
23
24
  };
25
+ const artifactSeg = buildArtifactRootSegment(root, cfg.baseExcludes, rt.isWin);
26
+ if (artifactSeg) notice.buildRootNotice = buildRootNotice(artifactSeg);
24
27
  }
25
28
  return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } };
26
29
  },
@@ -28,7 +31,15 @@ function createRoutesCore(deps) {
28
31
  const id = args && args.messageId ? String(args.messageId) : "";
29
32
  const snap = state.snapshots.get(id);
30
33
  const feedback = await snaps.feedbackFor(args ? args.sessionId : null, id);
31
- return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback };
34
+ const sroot = await rt.resolveRoot(args && args.sessionId ? String(args.sessionId) : null);
35
+ const artifactSeg = sroot ? buildArtifactRootSegment(sroot, cfg.baseExcludes, rt.isWin) : null;
36
+ return {
37
+ has: Boolean(snap),
38
+ time: snap ? snap.time : null,
39
+ id,
40
+ ...feedback,
41
+ notice: artifactSeg ? buildRootNotice(artifactSeg) : void 0
42
+ };
32
43
  },
33
44
  "preview": async (args) => {
34
45
  const id = args && args.messageId ? String(args.messageId) : "";
@@ -1,5 +1,6 @@
1
1
  import { isSafetySnapshotId } from "./snapshots.js";
2
2
  import { parseExcludeDump } from "./dump-parse.js";
3
+ import { resolveSettingsNs } from "./config.js";
3
4
  function createRoutesManage(deps) {
4
5
  const {
5
6
  ctx,
@@ -246,6 +247,9 @@ function createRoutesManage(deps) {
246
247
  },
247
248
  // 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段 + env
248
249
  // 锁定字段(环境变量优先级最高)+ 可写性(只读 provider 禁存)。
250
+ // ns 不写死:0.1.7 的 settings ns 是 profile entry id(本机实测 'recall'),
251
+ // ≤0.1.6 才是注册出来的 namespace 'dsh-recall'——统一由 resolveSettingsNs
252
+ // 按 describe 交集解析(解析不到时按「无覆盖」读,卡片仍展示 resolved 值)。
249
253
  "config-get": async () => {
250
254
  const envLocks = {
251
255
  gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
@@ -257,7 +261,8 @@ function createRoutesManage(deps) {
257
261
  const settings = ctx.get("settings");
258
262
  if (settings && typeof settings.describe === "function") {
259
263
  const list = settings.describe();
260
- const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === "dsh-recall");
264
+ const ns = resolveSettingsNs(ctx, settings);
265
+ const ours = ns ? (Array.isArray(list) ? list : []).find((d) => d && d.ns === ns) : null;
261
266
  if (ours && ours.user && typeof ours.user === "object") overridden = ours.user;
262
267
  writable = settings.writable !== false;
263
268
  }
@@ -282,7 +287,10 @@ function createRoutesManage(deps) {
282
287
  };
283
288
  },
284
289
  // 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
285
- // 写进用户层,watch 链路把新值热更新进 cfg,无需重启。
290
+ // 写进用户层(旧面)或 profile entry(新面),热更链路把新值更新进 cfg,
291
+ // 无需重启。ns 取解析结果——未解析到时与「服务不可用」同一出口:文案保留
292
+ // 「按 id: recall 覆盖配置」的逃生口(改 profile 的 cordis.patch.yml 仍是
293
+ // 任何版本下都能生效的兜底路径)。
286
294
  "config-set": async (args) => {
287
295
  const patch = args && args.patch && typeof args.patch === "object" ? args.patch : {};
288
296
  const clean = {};
@@ -313,11 +321,12 @@ function createRoutesManage(deps) {
313
321
  } catch (error) {
314
322
  settings = null;
315
323
  }
316
- if (!settings || typeof settings.update !== "function") {
324
+ const ns = settings ? resolveSettingsNs(ctx, settings) : null;
325
+ if (!settings || !ns || typeof settings.update !== "function") {
317
326
  return { ok: false, code: E.RECALL_SETTINGS_UNAVAILABLE, message: "\u8BBE\u7F6E\u670D\u52A1\u4E0D\u53EF\u7528\uFF1A\u8BF7\u5728 profile \u7684 cordis.patch.yml \u6309 id: recall \u8986\u76D6\u914D\u7F6E" };
318
327
  }
319
328
  try {
320
- await settings.update("dsh-recall", clean);
329
+ await settings.update(ns, clean);
321
330
  } catch (error) {
322
331
  const se = error;
323
332
  return { ok: false, code: E.RECALL_SETTINGS_WRITE_FAILED, message: "\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF1A" + String(se && se.message ? se.message : error) };
@@ -548,14 +557,15 @@ function createRoutesManage(deps) {
548
557
  } catch (error) {
549
558
  settings = null;
550
559
  }
551
- if (!settings || typeof settings.update !== "function") {
560
+ const ns = settings ? resolveSettingsNs(ctx, settings) : null;
561
+ if (!settings || !ns || typeof settings.update !== "function") {
552
562
  return { ok: false, code: E.RECALL_SETTINGS_UNAVAILABLE, message: "\u8BBE\u7F6E\u670D\u52A1\u4E0D\u53EF\u7528\uFF1A\u8BF7\u5728 profile \u7684 cordis.patch.yml \u6309 id: recall \u8986\u76D6\u914D\u7F6E" };
553
563
  }
554
564
  try {
555
565
  if (typeof settings.replace === "function") {
556
- await settings.replace("dsh-recall", {});
566
+ await settings.replace(ns, {});
557
567
  } else {
558
- await settings.update("dsh-recall", Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }));
568
+ await settings.update(ns, Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }));
559
569
  }
560
570
  } catch (error) {
561
571
  const re = error;
@@ -20,7 +20,19 @@ function dropGitlinksBlock() {
20
20
  }
21
21
  function oversizeBlock(maxBytes) {
22
22
  return [
23
- 'find "$root" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
23
+ "oversize_args=()",
24
+ 'while IFS= read -r line || [ -n "$line" ]; do',
25
+ " t=${line%$'\\r'}",
26
+ ' t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"',
27
+ ' if [ -z "$t" ]; then continue; fi',
28
+ ' case "$t" in \\#* | \\!*) continue ;; esac',
29
+ ' t="${t%/}"',
30
+ ' case "$t" in *\\** | *\\?* | *\\[* | */*) continue ;; esac',
31
+ ' if [ ${#oversize_args[@]} -eq 0 ]; then oversize_args+=(-name "$t"); else oversize_args+=(-o -name "$t"); fi',
32
+ 'done <<< "$new_exc"',
33
+ "oversize_prune=()",
34
+ "if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi",
35
+ 'find "$root" "${oversize_prune[@]}" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
24
36
  ` printf '%s\\0' "\${f#"$root"/}"`,
25
37
  'done | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true'
26
38
  ].join("\n");
@@ -277,6 +289,9 @@ function gcScript(store, gitExe) {
277
289
  "set -e",
278
290
  "git=" + psq(gitExe),
279
291
  "g=" + psq(store.git),
292
+ 'if [ -z "$("$git" --git-dir="$g" for-each-ref --count=1 refs)" ] && [ -n "$("$git" --git-dir="$g" ls-files)" ]; then',
293
+ ' "$git" --git-dir="$g" read-tree --empty',
294
+ "fi",
280
295
  '"$git" --git-dir="$g" gc --quiet --prune=now',
281
296
  'date +%s > "$g/gc.stamp"',
282
297
  "echo GC_OK"
@@ -19,6 +19,13 @@ function dropGitlinksBlock() {
19
19
  }
20
20
  function oversizeBlock(maxBytes) {
21
21
  return [
22
+ "$oversizeSkip = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)",
23
+ "foreach ($p in $lines) {",
24
+ ' $t = "$p".Trim()',
25
+ " if (-not $t -or $t.StartsWith('#') -or $t.StartsWith('!')) { continue }",
26
+ " $t = $t -replace '/$', ''",
27
+ " if ($t -notmatch '[*?\\[\\]/\\\\]') { [void]$oversizeSkip.Add($t) }",
28
+ "}",
22
29
  "$oversizeStack = [System.Collections.Generic.Stack[string]]::new()",
23
30
  "$oversizeStack.Push($root)",
24
31
  "$oversizeRel = [System.Collections.Generic.List[string]]::new()",
@@ -31,7 +38,9 @@ function oversizeBlock(maxBytes) {
31
38
  " $oversizeRel.Add($f.FullName.Substring($root.Length + 1).Replace('\\','/'))",
32
39
  " }",
33
40
  " }",
34
- " foreach ($d in $di.EnumerateDirectories()) { $oversizeStack.Push($d.FullName) }",
41
+ " foreach ($d in $di.EnumerateDirectories()) {",
42
+ " if (-not $oversizeSkip.Contains($d.Name)) { $oversizeStack.Push($d.FullName) }",
43
+ " }",
35
44
  " } catch {}",
36
45
  "}",
37
46
  "for ($i = 0; $i -lt $oversizeRel.Count; $i += 100) {",
@@ -306,7 +315,11 @@ function gcScript(store, gitExe) {
306
315
  "$ErrorActionPreference = 'Stop'",
307
316
  "$git = " + psq(gitExe),
308
317
  "$g = " + psq(store.git),
318
+ "$gcRefs = & $git --git-dir=$g for-each-ref --count=1 refs",
319
+ "$gcFiles = & $git --git-dir=$g ls-files",
320
+ "if (-not $gcRefs -and $gcFiles) { & $git --git-dir=$g read-tree --empty }",
309
321
  "& $git --git-dir=$g gc --quiet --prune=now",
322
+ 'if ($LASTEXITCODE -ne 0) { throw ("git gc failed (exit " + $LASTEXITCODE + ")") }',
310
323
  "Set-Content -LiteralPath (Join-Path $g 'gc.stamp') -Value ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) -Encoding ascii",
311
324
  "Write-Output 'GC_OK'"
312
325
  ].join("\n");
@@ -0,0 +1,43 @@
1
+ import { Config, LEGACY_SETTINGS_NS, isLegacySettingsFace, resolveSettingsNs, unwrapConfig } from "./config.js";
2
+ function newSettingsNs(ctx, settings) {
3
+ if (!settings) return null;
4
+ if (isLegacySettingsFace(settings)) return null;
5
+ if (typeof settings.describe !== "function" || typeof settings.update !== "function") return null;
6
+ try {
7
+ return resolveSettingsNs(ctx, settings);
8
+ } catch (error) {
9
+ return null;
10
+ }
11
+ }
12
+ function installSettingsNamespace(deps) {
13
+ const { ctx, dshSettings, config, settingsHooks, applyResolvedConfig, recordError } = deps;
14
+ try {
15
+ const legacyEntry = unwrapConfig(config);
16
+ if (typeof dshSettings.installSettingsSection === "function") {
17
+ dshSettings.installSettingsSection(ctx, LEGACY_SETTINGS_NS, Config, legacyEntry, settingsHooks);
18
+ } else if (typeof ctx.inject === "function") {
19
+ ctx.inject(["settings"], (settingsCtx) => {
20
+ const settingsService = settingsCtx.settings;
21
+ if (newSettingsNs(ctx, settingsService)) {
22
+ ctx.on("loader/volatile-update", () => applyResolvedConfig(config));
23
+ } else if (typeof settingsService.installSection === "function") {
24
+ settingsService.installSection(ctx, LEGACY_SETTINGS_NS, Config, legacyEntry, settingsHooks);
25
+ } else if (typeof settingsService.register === "function") {
26
+ const scope = settingsService.register(LEGACY_SETTINGS_NS, Config, { base: legacyEntry });
27
+ settingsHooks.setSource(() => scope.get());
28
+ settingsHooks.onChange();
29
+ scope.watch(() => settingsHooks.onChange());
30
+ settingsCtx.effect(() => () => {
31
+ settingsHooks.setSource(() => legacyEntry);
32
+ settingsHooks.onChange();
33
+ });
34
+ }
35
+ });
36
+ }
37
+ } catch (error) {
38
+ recordError("recall settings namespace skipped: " + String(error));
39
+ }
40
+ }
41
+ export {
42
+ installSettingsNamespace
43
+ };
package/lib/snapshots.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as E from "./errors.js";
2
2
  import { buildFeedbackError } from "./diagnostics.js";
3
+ import { buildArtifactRootSegment } from "./exclude-patterns.js";
3
4
  function parseSkipped(out) {
4
5
  const skipped = [];
5
6
  for (const line of String(out || "").split(/\r?\n/)) {
@@ -309,6 +310,7 @@ function createSnapshots(ctx, rt, config) {
309
310
  if (!root) return;
310
311
  const fused = snapFailures.get(root);
311
312
  if (fused && Date.now() < fused.skipUntil) return;
313
+ if (buildArtifactRootSegment(root, BASE(), rt.isWin)) return;
312
314
  let store = await rt.resolveStore(root);
313
315
  store = await rt.tryUpgradeToHome(root);
314
316
  const g = await rt.ensureGit(root, store);
package/lib/store.js CHANGED
@@ -128,6 +128,14 @@ async function runViaSpawn(spawn, req) {
128
128
  }
129
129
  });
130
130
  }
131
+ async function runViaExecutor(shell, spec) {
132
+ if (typeof shell.run === "function") return await shell.run(spec);
133
+ if (typeof shell.execute === "function") {
134
+ const handle = await shell.execute(spec);
135
+ return await handle.result();
136
+ }
137
+ throw new Error("recall: ctx.shell \u672A\u63D0\u4F9B run \u4E5F\u672A\u63D0\u4F9B execute\uFF0C\u65E0\u6CD5\u6267\u884C\u547D\u4EE4\uFF08dsh \u6267\u884C\u63A5\u7F1D\u4E0D\u517C\u5BB9\uFF09");
138
+ }
131
139
  function createRuntime(ctx, config, deps) {
132
140
  const shell = ctx.shell;
133
141
  const sessions = ctx.sessions;
@@ -198,7 +206,7 @@ function createRuntime(ctx, config, deps) {
198
206
  let res = null;
199
207
  try {
200
208
  const sp = ctx.get("sandboxPolicy");
201
- res = await shell.run(shell.resolve({
209
+ res = await runViaExecutor(shell, shell.resolve({
202
210
  command: SHELL_PROBE_COMMAND,
203
211
  timeoutMs: 3e4,
204
212
  stdoutMaxBytes: 4096,
@@ -250,9 +258,12 @@ function createRuntime(ctx, config, deps) {
250
258
  ...opts && opts.stdin !== void 0 ? { stdin: opts.stdin } : {},
251
259
  sandboxPolicy: { mode: "danger-full-access", workspaceRoot }
252
260
  });
253
- const res = await shell.run(spec);
261
+ const res = await runViaExecutor(shell, spec);
254
262
  if (res && res.exitCode !== 0) {
255
- await throwShellFailure(command, res && res.stderr && res.stderr.text || "", res.exitCode);
263
+ const stderr = res && res.stderr && res.stderr.text || "";
264
+ const timedOut = res.timedOut === true || res.exitCode === null && !String(stderr).trim();
265
+ const detail = timedOut ? "\u547D\u4EE4\u51C6\u5907\u671F\u8D85\u65F6\uFF08" + String(timeoutMs) + "ms\uFF0C\u6267\u884C\u5668\u672A\u4EA7\u51FA\u8F93\u51FA\uFF09" : stderr;
266
+ await throwShellFailure(command, detail, res.exitCode);
256
267
  }
257
268
  return {
258
269
  text: res && res.stdout && res.stdout.text || "",
@@ -504,6 +515,7 @@ export {
504
515
  judgeShellDialect,
505
516
  parseCleanupResult,
506
517
  resolvePosixHomeBase,
518
+ runViaExecutor,
507
519
  runViaSpawn,
508
520
  scrubChildEnv,
509
521
  selectPosixHomeBase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.3.24",
3
+ "version": "2.4.2",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -58,18 +58,20 @@
58
58
  "0.1.5-rc.1": "compatible",
59
59
  "0.1.5-rc.2": "compatible",
60
60
  "0.1.6-alpha.1": "compatible",
61
- "0.1.6-alpha.2": "compatible"
61
+ "0.1.6-alpha.2": "compatible",
62
+ "0.1.7-alpha.1": "compatible",
63
+ "0.1.7-alpha.2": "compatible"
62
64
  }
63
65
  }
64
66
  },
65
67
  "peerDependencies": {
66
68
  "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-host-webserver": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
68
- "@deepseek-ai/dsh-sandbox-policy": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
69
- "@deepseek-ai/dsh-session": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
70
- "@deepseek-ai/dsh-session-query": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
71
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
72
- "@deepseek-ai/dsh-shell": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7",
69
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
70
+ "@deepseek-ai/dsh-sandbox-policy": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
71
+ "@deepseek-ai/dsh-session": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
72
+ "@deepseek-ai/dsh-session-query": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
73
+ "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
74
+ "@deepseek-ai/dsh-shell": ">=0.1.2-alpha.1 <0.1.3 || >=0.1.3-alpha.1 <0.1.4 || >=0.1.5-alpha.1 <0.1.6 || >=0.1.6-alpha.1 <0.1.7 || >=0.1.7-alpha.1 <0.1.8",
73
75
  "@deepseek-ai/schemastery": "^3.18.1",
74
76
  "react": "^18.2.0"
75
77
  },
@@ -80,6 +82,7 @@
80
82
  },
81
83
  "license": "MIT",
82
84
  "devDependencies": {
85
+ "@deepseek-ai/schemastery": "^3.18.4",
83
86
  "@types/node": "^20.19.43",
84
87
  "@types/react": "^18.3.31",
85
88
  "esbuild": "^0.28.1",