dsh-archived-chats 1.0.3 → 1.0.5

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
@@ -76,7 +76,7 @@ Features activate from the public capabilities exposed by the DeepSeek Harness H
76
76
  | --- | --- |
77
77
  | Archive and session reads | Browsing, search, preview, History inventory, storage accounting, and lineage. |
78
78
  | Attachment reads | Stored images appear in conversation and snapshot previews; without it, text remains readable. |
79
- | Public persistence writer | Required for ZIP import, **Restore as copy**, and snapshot fallback when an original is missing. |
79
+ | Public session writer | ZIP import, **Restore as copy**, and snapshot fallback when an original is missing all write through the Host's public `create` / `append` / `locate` capability, or a dedicated restore entry point where one exists. |
80
80
  | Missing write capability | The operation returns `restore-unsupported` without writing or overwriting data. |
81
81
 
82
82
  Back up `$DSH_HOME/plugin-data/archived-chats/` before downgrading to a release that does not display History or understand recycle snapshots.
package/README.zh-CN.md CHANGED
@@ -76,7 +76,7 @@ dsh plugin --profile web update dsh-archived-chats
76
76
  | --- | --- |
77
77
  | 归档与会话读取 | 浏览、搜索、预览、历史清单、空间分账和会话血缘。 |
78
78
  | 附件读取 | 对话和快照预览可显示已存储图片;缺少时文本内容仍可阅读。 |
79
- | 公开持久层 writer | ZIP 导入、**恢复为副本**,以及原件丢失时的快照回退恢复需要此能力。 |
79
+ | 公开会话 writer | ZIP 导入、**恢复为副本** 和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
80
80
  | 缺少写入能力 | 操作返回 `restore-unsupported`,不会写入或覆盖数据。 |
81
81
 
82
82
  降级到不显示历史版本或不识别回收快照的版本前,请备份 `$DSH_HOME/plugin-data/archived-chats/`。
@@ -68,7 +68,7 @@ Metadata and recycle catalogs are versioned. Writes serialize and atomically rep
68
68
 
69
69
  The stats route measures session directories with concurrency four, skips symbolic links, and caches results for 30 seconds. A measurement failure marks only that row unavailable; list and mutation actions continue. Delete invalidates the affected cache row.
70
70
 
71
- Insights joins session measurement with a stream-verified snapshot inventory and counts repeated snapshot attachments only from validated SHA-256 descriptors. The browser keeps totals in summary cards and presents session/snapshot inventories only in bounded searchable dialogs. retention.json uses an exact version-one schema; saving never runs cleanup. Preview issues a five-minute single-use token/nonce and apply revalidates inside the lifecycle queue; recycle candidates still delegate to recycle purge. Lineage uses only durable parentSession edges, caps real nodes at 5,000, never rewrites headers, and resolves titles for at most 100 untitled active source nodes already included in the focused tree.
71
+ Insights joins session measurement with a stream-verified snapshot inventory and counts repeated snapshot attachments only from validated SHA-256 descriptors. The browser keeps totals in summary cards and presents session/snapshot inventories only in bounded searchable dialogs. retention.json uses an exact version-one schema; saving never runs cleanup. Preview issues a five-minute single-use token/nonce and apply revalidates inside the lifecycle queue; recycle candidates still delegate to recycle purge. Lineage uses only durable parentSession edges, never rewrites headers, and resolves titles for at most 100 untitled active source nodes already included in the focused tree. Its 5,000-node cap bounds the PROJECTED graph, not the Host's store: `focusIds` narrows the output to archived and recycled chats plus their explaining context, so a store of 100,000 sessions with 25 archived projects 25 nodes. Header fields are coerced per node rather than validated whole-graph — an unanticipated `origin` value, an absent `createdAt`, a changed numeric type each degrade that node to a value the public LineageNode already allows, and only an unusable identity drops a row. A projection that rejected the whole graph for one unrecognized header let a Host-side change disable the panel with no plugin change at all; malformed workspace and recycle entries are skipped for the same reason.
72
72
 
73
73
  ## Preview and full-text search
74
74
 
@@ -86,7 +86,7 @@ Cross-session persistence inspection is limited to four concurrent reads, stops
86
86
 
87
87
  `history-restore.js` fully validates the snapshot, asks the Host for a new session ID, and issues a five-minute single-use token/nonce. Confirmation consumes the credential before writes and rechecks the manifest, then creates persistence, rewrites session/attachment identities, appends events, restores workspace and metadata, and commits archive registry state last. Failures reverse plugin-controlled steps. The source session and snapshot never change, and the plugin makes no claim that Host-global attachment objects were deleted.
88
88
 
89
- Single-version deletion and **Clear history versions** both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed. Single deletion rejects `recycle-protection`; clear removes only healthy ordinary history and skips recycle-protection and degraded snapshots. Deletion physically removes the plugin snapshot and its attachment copies, so that version can no longer be previewed or restored, while the original chat and other versions remain unchanged.
89
+ Single-version deletion and **Clear history versions** both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed. Both reject a snapshot a recycle record still names, degraded or not that record is its last claim. Both otherwise accept a degraded snapshot: it can no longer be previewed or restored from, but its bytes are still on disk and no other surface can reclaim them (retention plans only healthy snapshots), so refusing here would leak the store permanently. Deletion physically removes the plugin snapshot and its attachment copies, while the original chat and other versions remain unchanged.
90
90
 
91
91
  ## Export flow
92
92
 
@@ -113,19 +113,19 @@ import/inspect accepts only version-one ZIPs produced by this plugin. The Host s
113
113
  5. restore.js uses a feature-detected adapter to write sessions, metadata, and archive state.
114
114
  6. Any failure rolls back staged data and never overwrites an existing session.
115
115
 
116
- The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import is enabled only when the Host exposes writer, removal rollback, archive, and metadata capabilities; workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
116
+ The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import resolves a session writer by capability: a dedicated Host restore entry point when one exists, otherwise the ordinary `create` / `append` / `locate` surface — the same capability History restore-as-copy writes through, so import works wherever that works. The append writer carries its own session-scoped rollback (it confirms the located directory is the session's own before creating anything), so a separate removal capability is required only for a dedicated restore entry point. Archive and metadata write capabilities are still required. The staged id does not exist yet, so a session reader that fails closed on unknown ids is the expected answer to the capability probe and never aborts the restore. Workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
117
117
 
118
118
  ## Recycle and protection-snapshot lifecycle
119
119
 
120
120
  `trash.json` permits only `trashed`, `purge-pending`, and `degraded`. Legal transitions are `missing -> trashed`, `trashed/degraded -> purge-pending`, and removal of an existing state after a committed transaction. A `purge-pending` record cannot restore.
121
121
 
122
- Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync.
122
+ Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync. Windows cannot atomically replace a file or remove a directory entry while another handle is open on it — an indexer or antivirus scan is enough — so replaces and recursive removals retry the transient `EPERM` / `EACCES` / `EBUSY` codes there, bounded, and only there: on POSIX the same codes are permanent conditions and retrying would only delay the same failure. Snapshot publication also treats a rename refused onto an existing directory as a conflict after probing the destination, because Windows reports that as `EPERM` rather than `EEXIST`. Path containment is tested with the platform separator and rejects an absolute answer, so a Windows `..\` escape or a different drive letter cannot read as inside the root.
123
123
 
124
124
  Move ordering is: validate archive ownership → dispose or park a live session → capture and verify snapshot → recheck ownership → atomically commit `trashed` → invalidate caches. Ordinary move never removes the persistence artifact.
125
125
 
126
126
  Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the recycle record, without rewriting persistence; the snapshot remains history. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.
127
127
 
128
- Permanent purge persists `purge-pending` before physical writes, then removes the original, every validated snapshot for that source, and the recycle record. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
128
+ Permanent purge persists `purge-pending` before physical writes, then removes every snapshot for that source, then the original session, and finally the recycle record. The session delete is deliberately last: a failure before it leaves the original intact and the record completable, rather than a `purge-pending` record whose session is already gone and which can therefore neither restore nor complete. The snapshot sweep attributes each published snapshot by manifest identity so a snapshot that fails validation is still removed when it belongs to this session, and an unrelated unverifiable snapshot is skipped instead of aborting the sweep — corruption elsewhere in the store must never make a purge impossible. The recycle record also names its own snapshot id, covering one damaged past attribution. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Physical deletion additionally requires the located artifact to sit in a directory named for the session itself, so a backend layout that shares one parent between sessions can never have that parent removed. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
129
129
 
130
130
  ## Browser client
131
131
 
@@ -158,7 +158,7 @@ The browser never mutates files directly. After an operation, the Host response
158
158
 
159
159
  ## Compatibility and testing
160
160
 
161
- The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. Import, History restore-as-copy, and snapshot fallback when the original is missing require a public writer; without it they return `restore-unsupported` without mutation. Back up the complete plugin-data directory before downgrading to a release that does not display History or understand recycle snapshots.
161
+ The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. Import, History restore-as-copy, and snapshot fallback when the original is missing all write through the public `create` / `append` / `locate` capability, or a dedicated restore entry point where the Host offers one; only a Host exposing neither returns `restore-unsupported` without mutation. A capability set that no shipped Host satisfies is not an acceptable guard — it makes the feature permanently dead rather than gracefully degraded. Back up the complete plugin-data directory before downgrading to a release that does not display History or understand recycle snapshots.
162
162
 
163
163
  Coverage includes:
164
164
 
@@ -68,7 +68,7 @@ $DSH_HOME/plugin-data/archived-chats/snapshots/
68
68
 
69
69
  stats 路由以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒。测量失败只标记当前行不可用,不阻塞列表和其他操作;删除会使对应缓存失效。
70
70
 
71
- insights 将会话目录测量与流式校验的快照清单分账,重复附件只按快照内已验证 SHA-256 统计;浏览器只在摘要卡片中显示总量,会话目录和快照明细通过有界、可搜索弹窗按需呈现。retention.json 使用精确 version 1 schema;保存策略不执行清理。preview 生成五分钟、单次使用的 token/nonce,apply 在生命周期队列内重检候选,回收站候选仍委托 recycle purge。lineage 只用持久化 parentSession 建树,最多 5,000 个真实节点,不修改会话头;对已经进入聚焦关系树但没有安全标题的活动来源节点,最多按需读取 100 个标题事件。
71
+ insights 将会话目录测量与流式校验的快照清单分账,重复附件只按快照内已验证 SHA-256 统计;浏览器只在摘要卡片中显示总量,会话目录和快照明细通过有界、可搜索弹窗按需呈现。retention.json 使用精确 version 1 schema;保存策略不执行清理。preview 生成五分钟、单次使用的 token/nonce,apply 在生命周期队列内重检候选,回收站候选仍委托 recycle purge。lineage 只用持久化 parentSession 建树,不修改会话头;对已经进入聚焦关系树但没有安全标题的活动来源节点,最多按需读取 100 个标题事件。其 5,000 节点上限约束的是**实际投影出的**关系图,而不是 Host 的会话库:`focusIds` 已把输出收窄为归档与回收聊天加上解释它们所需的上下文,因此 100,000 个会话、25 条归档只投影 25 个节点。会话头字段按节点逐项收敛而不是整图校验——未预期的 `origin` 取值、缺失的 `createdAt`、变化的数值类型,各自只把该节点降级为公开 LineageNode 已允许的取值,只有身份不可用才丢弃该行。为一个不认识的会话头拒绝整张图,会让 Host 侧的变化在插件毫无改动的情况下直接停用该面板;畸形的工作区与回收记录出于同样理由被跳过。
72
72
 
73
73
  ## 预览和全文搜索
74
74
 
@@ -86,7 +86,7 @@ preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chat
86
86
 
87
87
  `history-restore.js` 先完整验证快照,用 Host 生成新会话 ID,再签发五分钟、单次使用的 token/nonce。确认时先消费凭据并重验 manifest;然后依次创建持久会话、重写会话/附件身份、附加事件、恢复工作区和元数据,最后才写入归档注册表。任一插件控制的边界失败都按逆序回滚;来源会话与快照始终不变,也不声称删除了 Host 全局附件对象。
88
88
 
89
- 历史页的单条删除与「清空历史版本」都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与回收保护关系。单条删除拒绝 `recycle-protection`;清空只删除健康普通历史,跳过回收保护和降级快照。删除会物理移除插件快照及其附件副本,无法再预览或恢复,但不修改原聊天或其他版本。
89
+ 历史页的单条删除与「清空历史版本」都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与回收保护关系。两者都拒绝仍被回收记录引用的快照(无论是否降级)——那条记录是它最后的归属。除此之外两者都接受降级快照:它已无法预览或恢复,但字节仍在磁盘上,而其他任何入口都无法回收它们(保留策略只规划健康快照),在这里拒绝就意味着永久泄漏。删除会物理移除插件快照及其附件副本,但不修改原聊天或其他版本。
90
90
 
91
91
  ## 导出流程
92
92
 
@@ -113,19 +113,19 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
113
113
  5. restore.js 通过能力探测的适配器写入会话、元数据和归档状态。
114
114
  6. 任一步骤失败都回滚暂存数据,不覆盖已有会话。
115
115
 
116
- 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。只有同时提供 writer、删除回滚、归档和元数据能力的宿主才会启用导入;工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
116
+ 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是历史版本恢复为副本写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
117
117
 
118
118
  ## 回收与保护快照生命周期
119
119
 
120
120
  `trash.json` 的合法状态只有 `trashed`、`purge-pending`、`degraded`。合法转换为 `missing -> trashed`、`trashed/degraded -> purge-pending`,以及任一现有状态在事务成功后移除。`purge-pending` 不得恢复。
121
121
 
122
- 保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。
122
+ 保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。Windows 在目标仍被其他句柄打开时无法原子替换文件或移除目录项(索引服务或杀毒扫描即可导致),因此替换与递归删除会在该平台上、且仅在该平台上,对瞬时的 `EPERM` / `EACCES` / `EBUSY` 做有界重试:POSIX 上这些码表示永久条件,重试只会延后同一个失败。快照发布还会在 rename 被拒时探测目标目录再判定为冲突,因为 Windows 把「rename 到已存在目录」报成 `EPERM` 而不是 `EEXIST`。路径包含判定使用平台分隔符并拒绝绝对结果,因此 Windows 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
123
123
 
124
124
  移入顺序为:校验归档所有权 → 处置/停放运行中会话 → 捕获并验证快照 → 再次校验所有权 → 原子写入 `trashed` 记录 → 使缓存失效。普通移入不删除持久层文件。
125
125
 
126
126
  恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除回收记录,不重写持久层,保护快照保留为历史;原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
127
127
 
128
- 永久删除在任何物理写入前持久化 `purge-pending`,再删除原会话、该来源的全部已验证快照和回收记录。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
128
+ 永久删除在任何物理写入前持久化 `purge-pending`,接着删除该来源的全部快照,然后删除原会话,最后移除回收记录。会话删除刻意放在最后:在它之前失败会保留完好的原件和可继续完成的记录,而不是留下一条会话已消失、既不能恢复也不能完成的 `purge-pending`。快照清扫按 manifest 身份归属每个已发布快照,因此校验失败但确属该会话的快照仍会被删除,而无关的无法校验快照会被跳过而不是中断整个清扫——快照库别处的损坏绝不能让永久删除变成不可能。回收记录还会点名自己的快照 id,覆盖损坏到无法归属的那一个。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。物理删除另外要求定位到的文件位于以该会话自身命名的目录中,因此当后端布局让多个会话共享同一父目录时,那个父目录永远不会被删除。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
129
129
 
130
130
  ## 浏览器客户端
131
131
 
@@ -158,7 +158,7 @@ client.js 注册 order 30 的 settings.section,并使用 Harness 公开的浮
158
158
 
159
159
  ## 兼容性和测试
160
160
 
161
- 插件通过能力检测适配 Host:归档读取、附件读取、持久层写入和运行中会话生命周期能力分别判断,缺失能力必须安全降级或返回明确错误。导入、历史版本恢复为副本和原件丢失时的快照回退需要公开 writer;缺少时返回 `restore-unsupported` 且不写入数据。旧版若不显示历史页或不识别回收快照,降级前应备份整个插件数据目录。
161
+ 插件通过能力检测适配 Host:归档读取、附件读取、持久层写入和运行中会话生命周期能力分别判断,缺失能力必须安全降级或返回明确错误。导入、历史版本恢复为副本和原件丢失时的快照回退都通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。旧版若不显示历史页或不识别回收快照,降级前应备份整个插件数据目录。
162
162
 
163
163
  测试覆盖:
164
164
 
package/lib/client.js CHANGED
@@ -261,6 +261,9 @@ window.__ModuleLoader__.load({
261
261
  "history.scope.history-only": "仅历史保留",
262
262
  "history.degraded": "无法读取的历史版本",
263
263
  "history.degradedItem": "无法验证",
264
+ "history.degradedDelete": "清除",
265
+ "history.degradedDeleteTitle": "清除这个无法读取的历史版本?",
266
+ "trash.listUnverified": "回收站目录无法读取:下面的列表可能包含已删除的聊天,归档修改已暂停。",
264
267
  "insights.loading": "正在分析空间…",
265
268
  "insights.error": "空间分析暂不可用",
266
269
  "insights.scopeNote": "这里只统计已归档、回收站会话,以及本插件为它们创建并继续保留的恢复快照。恢复聊天后快照仍可保留,所以归档列表为空时这里仍可能有数据。",
@@ -522,6 +525,9 @@ window.__ModuleLoader__.load({
522
525
  "history.scope.history-only": "History only",
523
526
  "history.degraded": "Unreadable history versions",
524
527
  "history.degradedItem": "Could not verify",
528
+ "history.degradedDelete": "Reclaim",
529
+ "history.degradedDeleteTitle": "Reclaim this unreadable history version?",
530
+ "trash.listUnverified": "The Recycle Bin catalog is unreadable: this list may include deleted chats, and archive changes are paused.",
525
531
  "insights.loading": "Analyzing storage…",
526
532
  "insights.error": "Storage insights are unavailable",
527
533
  "insights.scopeNote": "This page measures archived and recycled chats plus recovery snapshots this plugin created and retained for them. Restoring a chat may retain its snapshot, so storage can remain when the archive list is empty.",
@@ -1408,6 +1414,10 @@ window.__ModuleLoader__.load({
1408
1414
  return {
1409
1415
  sessions: Array.isArray(body.sessions) ? body.sessions : [],
1410
1416
  metadataStatus: body.metadataStatus === "unavailable" ? "unavailable" : "ready",
1417
+ // An unreadable recycle catalog cannot be subtracted from this list, so
1418
+ // already-deleted chats can reappear here. Surface it instead of
1419
+ // silently showing them as ordinary archived chats.
1420
+ trashStatus: body.trashStatus === "unavailable" ? "unavailable" : "ready",
1411
1421
  };
1412
1422
  }
1413
1423
 
@@ -3082,6 +3092,14 @@ window.__ModuleLoader__.load({
3082
3092
  setDeleteDialog({ kind: "one", session, version });
3083
3093
  setMessage(null);
3084
3094
  };
3095
+ // Unreadable versions still occupy the snapshot store, so they need their
3096
+ // own reclaim path — they never appear as a restorable version row.
3097
+ const askDeleteDegraded = (item) => {
3098
+ if (deleteBusy || restoreDialog !== null) return;
3099
+ deleteReturnFocusRef.current = document.activeElement;
3100
+ setDeleteDialog({ kind: "degraded", item });
3101
+ setMessage(null);
3102
+ };
3085
3103
  const askClearHistory = () => {
3086
3104
  if (clearSummary.versionCount === 0 || deleteBusy || restoreDialog !== null) return;
3087
3105
  deleteReturnFocusRef.current = document.activeElement;
@@ -3093,9 +3111,9 @@ window.__ModuleLoader__.load({
3093
3111
  const current = deleteDialog;
3094
3112
  setDeleteBusy(true);
3095
3113
  try {
3096
- const result = current.kind === "one"
3097
- ? await deleteHistoryVersion(current.version.snapshotId)
3098
- : await clearHistoryVersions();
3114
+ const result = current.kind === "all"
3115
+ ? await clearHistoryVersions()
3116
+ : await deleteHistoryVersion(current.kind === "degraded" ? current.item.snapshotId : current.version.snapshotId);
3099
3117
  setDeleteDialog(null);
3100
3118
  await load();
3101
3119
  const deletedCount = Array.isArray(result?.deleted) ? result.deleted.length : 0;
@@ -3116,7 +3134,11 @@ window.__ModuleLoader__.load({
3116
3134
  setDeleteBusy(false);
3117
3135
  }
3118
3136
  };
3119
- const deleteBody = deleteDialog === null ? "" : deleteDialog.kind === "one"
3137
+ const deleteBody = deleteDialog === null ? "" : deleteDialog.kind === "degraded"
3138
+ ? (isZh(t)
3139
+ ? `该历史版本已无法读取或校验,不能再用于恢复。清除后将释放它占用的磁盘空间;原聊天不会被删除。`
3140
+ : `This history version can no longer be read or verified, so it cannot be restored from. Reclaiming it frees the disk space it still occupies. The original chat is not deleted.`)
3141
+ : deleteDialog.kind === "one"
3120
3142
  ? (() => {
3121
3143
  const title = deleteDialog.session.title || t("chat.untitled");
3122
3144
  const createdMs = Date.parse(deleteDialog.version.createdAt);
@@ -3210,6 +3232,13 @@ window.__ModuleLoader__.load({
3210
3232
  ...state.degraded.map((item) => (0, jsx.jsxs)("div", { className: "dac-history-degraded-row", children: [
3211
3233
  (0, jsx.jsx)("code", { children: item.snapshotId }),
3212
3234
  (0, jsx.jsx)("span", { children: t("history.degradedItem") }),
3235
+ (0, jsx.jsx)("button", {
3236
+ type: "button",
3237
+ className: "dac-btn dac-history-delete",
3238
+ disabled: deleteBusy || deleteDialog !== null || restoreDialog !== null,
3239
+ onClick: () => askDeleteDegraded(item),
3240
+ children: t("history.degradedDelete"),
3241
+ }),
3213
3242
  ] }, item.snapshotId)),
3214
3243
  ] }),
3215
3244
  restoreDialog !== null && (0, jsx.jsx)(ConfirmDialog, {
@@ -3225,9 +3254,9 @@ window.__ModuleLoader__.load({
3225
3254
  onCancel: () => setRestoreDialog(null),
3226
3255
  }),
3227
3256
  deleteDialog !== null && (0, jsx.jsx)(ConfirmDialog, {
3228
- title: t(deleteDialog.kind === "one" ? "history.deleteTitle" : "history.clearTitle"),
3257
+ title: t(deleteDialog.kind === "one" ? "history.deleteTitle" : deleteDialog.kind === "degraded" ? "history.degradedDeleteTitle" : "history.clearTitle"),
3229
3258
  body: deleteBody,
3230
- confirmLabel: t(deleteDialog.kind === "one" ? "history.deleteConfirm" : "history.clear"),
3259
+ confirmLabel: t(deleteDialog.kind === "all" ? "history.clear" : "history.deleteConfirm"),
3231
3260
  cancelLabel: t("confirm.cancel"),
3232
3261
  busy: deleteBusy,
3233
3262
  returnFocus: deleteReturnFocusRef.current,
@@ -3259,6 +3288,7 @@ window.__ModuleLoader__.load({
3259
3288
  const [selected, setSelected] = _react.useState(() => new Set());
3260
3289
  const [tagFilter, setTagFilter] = _react.useState("");
3261
3290
  const [metadataStatus, setMetadataStatus] = _react.useState("ready");
3291
+ const [archiveTrashStatus, setArchiveTrashStatus] = _react.useState("ready");
3262
3292
  const [stats, setStats] = _react.useState({ status: "idle", summary: null, sessions: {} });
3263
3293
  const [metadataEdit, setMetadataEdit] = _react.useState(null);
3264
3294
  const [metaBusy, setMetaBusy] = _react.useState(false);
@@ -3346,6 +3376,7 @@ window.__ModuleLoader__.load({
3346
3376
  const loaded = await fetchState();
3347
3377
  setSessions(loaded.sessions);
3348
3378
  setMetadataStatus(loaded.metadataStatus);
3379
+ setArchiveTrashStatus(loaded.trashStatus);
3349
3380
  setStats({ status: "idle", summary: null, sessions: {} });
3350
3381
  } catch (error) {
3351
3382
  setLoadError(error);
@@ -3885,7 +3916,9 @@ window.__ModuleLoader__.load({
3885
3916
  }, [sessions, query, projectFilter, typeFilter, tagFilter, sortMode, contentHits, t]);
3886
3917
 
3887
3918
  if (sessions === null && loadError === null) {
3888
- return (0, jsx.jsx)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), (0, jsx.jsx)("span", { children: t("state.loading") })] });
3919
+ // jsxs is the runtime for a static children array; jsx would make React
3920
+ // treat it as a dynamic list and warn about missing keys on every open.
3921
+ return (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), (0, jsx.jsx)("span", { children: t("state.loading") })] });
3889
3922
  }
3890
3923
  if (loadError !== null && sessions === null) {
3891
3924
  return (0, jsx.jsxs)("div", {
@@ -3993,6 +4026,11 @@ window.__ModuleLoader__.load({
3993
4026
  role: "status",
3994
4027
  children: t("meta.unavailable")
3995
4028
  }),
4029
+ archiveTrashStatus === "unavailable" && (0, jsx.jsx)("div", {
4030
+ className: "dac-warn",
4031
+ role: "status",
4032
+ children: t("trash.listUnverified")
4033
+ }),
3996
4034
  (0, jsx.jsxs)("div", {
3997
4035
  className: "dac-search",
3998
4036
  children: [
package/lib/durable.js CHANGED
@@ -4,6 +4,15 @@ import { dirname } from 'node:path';
4
4
 
5
5
  const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
6
6
  const WINDOWS_DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EISDIR', 'EPERM']);
7
+ /**
8
+ * Windows has no atomic-replace guarantee while another handle is open on the
9
+ * destination — an indexer or antivirus scan is enough to fail the rename with
10
+ * EPERM/EACCES/EBUSY. These are transient, so the replace is retried briefly.
11
+ * POSIX rename never reports them for this operation, so the loop is a no-op.
12
+ */
13
+ const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
14
+ const RENAME_ATTEMPTS = 10;
15
+ const RENAME_DELAY_MS = 20;
7
16
 
8
17
  function directorySyncUnsupported(error) {
9
18
  return DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)
@@ -35,6 +44,28 @@ export async function syncDirectory(path, openFile = open) {
35
44
  }
36
45
  }
37
46
 
47
+ /**
48
+ * Replace one file by rename, retrying only the transient Windows codes. On
49
+ * POSIX these codes are permanent conditions, so retrying there would just add
50
+ * latency before the same failure.
51
+ */
52
+ export async function replaceFile(tempPath, filePath, {
53
+ renameFile = rename,
54
+ platform = process.platform,
55
+ delayMs = RENAME_DELAY_MS,
56
+ attempts = RENAME_ATTEMPTS,
57
+ } = {}) {
58
+ for (let attempt = 1; ; attempt += 1) {
59
+ try {
60
+ await renameFile(tempPath, filePath);
61
+ return attempt;
62
+ } catch (error) {
63
+ if (attempt >= attempts || platform !== 'win32' || !RENAME_RETRY_CODES.has(error?.code)) throw error;
64
+ await new Promise((resolve) => { setTimeout(resolve, delayMs * attempt); });
65
+ }
66
+ }
67
+ }
68
+
38
69
  /** Atomically replace one private file and make the rename crash-durable. */
39
70
  export async function atomicWriteFile(filePath, data, { encoding = undefined } = {}) {
40
71
  const directory = dirname(filePath);
@@ -59,7 +90,7 @@ export async function atomicWriteFile(filePath, data, { encoding = undefined } =
59
90
  }
60
91
  await chmod(tempPath, 0o600);
61
92
  await syncFile(tempPath);
62
- await rename(tempPath, filePath);
93
+ await replaceFile(tempPath, filePath);
63
94
  tempPath = null;
64
95
  await syncDirectory(directory);
65
96
  } finally {
package/lib/export.js CHANGED
@@ -14,6 +14,17 @@ function codePoints(value) {
14
14
  return [...value];
15
15
  }
16
16
 
17
+ /**
18
+ * Neutralize a Windows device name. The reservation covers every extension too
19
+ * (`NUL.txt` is the device), so the marker has to change the base name instead
20
+ * of trailing the whole segment.
21
+ */
22
+ function deviceSafe(segment) {
23
+ if (!RESERVED_BASENAME.test(segment)) return segment;
24
+ const dot = segment.indexOf('.');
25
+ return dot === -1 ? `${segment}-file` : `${segment.slice(0, dot)}-file${segment.slice(dot)}`;
26
+ }
27
+
17
28
  /** Normalize untrusted text into one cross-platform archive path segment. */
18
29
  export function safeSegment(value, fallback = 'untitled', maxLength = 80) {
19
30
  const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : 80;
@@ -25,13 +36,13 @@ export function safeSegment(value, fallback = 'untitled', maxLength = 80) {
25
36
  .replace(/^[. -]+|[. -]+$/g, '');
26
37
 
27
38
  if (segment === '') segment = String(fallback).normalize('NFKC');
28
- if (RESERVED_BASENAME.test(segment)) segment = `${segment}-file`;
29
39
 
40
+ // Truncate first: shortening can itself expose a reserved base name, and the
41
+ // marker is applied exactly once afterwards so it never stacks.
30
42
  segment = codePoints(segment).slice(0, limit).join('')
31
43
  .replace(/^[. -]+|[. -]+$/g, '');
32
44
  if (segment === '') return 'untitled';
33
- if (RESERVED_BASENAME.test(segment)) return `${segment}-file`;
34
- return segment;
45
+ return deviceSafe(segment);
35
46
  }
36
47
 
37
48
  function nullableString(value) {
@@ -175,7 +175,7 @@ export function createHistoryRestoreService({
175
175
  await persistence.removeSession(destinationId);
176
176
  return;
177
177
  }
178
- await rm(dirname(persistenceLocation.path), { recursive: true, force: true });
178
+ await rm(dirname(persistenceLocation.path), { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
179
179
  }
180
180
 
181
181
  async function restore(token, nonce) {
@@ -192,7 +192,7 @@ export function createHistoryRestoreService({
192
192
  if (currentIds.has(entry.destinationId)) throw failure('history-restore-conflict', 'history destination already exists', 409);
193
193
  const meta = structuredClone(checked.record.source.meta);
194
194
  meta.id = entry.destinationId;
195
- const location = persistence.locate(meta);
195
+ const location = await persistence.locate(meta);
196
196
  if (typeof location?.path !== 'string' || basename(dirname(location.path)) !== entry.destinationId) {
197
197
  throw failure('history-restore-unsupported', 'history restore rollback location is unavailable', 501);
198
198
  }
package/lib/history.js CHANGED
@@ -76,6 +76,23 @@ export function createHistoryService({
76
76
  cache = null;
77
77
  }
78
78
 
79
+ /**
80
+ * Snapshot ids a recycle record still points at. They protect a recycled
81
+ * session and must never be deleted from the History surface, degraded or
82
+ * not — the record is the only claim left on them.
83
+ */
84
+ async function protectedSnapshots() {
85
+ const trash = await trashStore.load();
86
+ if (trash?.status !== 'ready' || !(trash.records instanceof Map)) {
87
+ throw failure('history-authority-unavailable', 'history authority is unavailable', 503);
88
+ }
89
+ const ids = new Set();
90
+ for (const record of trash.records.values()) {
91
+ if (typeof record?.snapshotId === 'string' && record.snapshotId !== '') ids.add(record.snapshotId);
92
+ }
93
+ return ids;
94
+ }
95
+
79
96
  async function sourceRevision(sessionId, live) {
80
97
  if (typeof persistence.listSnapshots !== 'function') {
81
98
  if (live) throw failure('history-revision-unavailable', 'stable session revision is unavailable', 501);
@@ -276,14 +293,20 @@ export function createHistoryService({
276
293
  if (typeof snapshotStore.remove !== 'function') throw failure('history-delete-unsupported', 'history deletion is unavailable', 501);
277
294
  return lifecycle.run(async () => {
278
295
  invalidate();
279
- const version = versionFromInventory(await compute(), snapshotId);
280
- if (version.state === 'recycle-protection') {
296
+ const inventory = await compute();
297
+ // A degraded snapshot can no longer be read, so it is not a restorable
298
+ // version — but its bytes are still on disk and it must stay deletable,
299
+ // or the store leaks with no surface that can reclaim it.
300
+ const damaged = inventory.degraded.find((item) => item.snapshotId === snapshotId);
301
+ const version = damaged === undefined ? versionFromInventory(inventory, snapshotId) : null;
302
+ const protectedIds = await protectedSnapshots();
303
+ if (version?.state === 'recycle-protection' || protectedIds.has(snapshotId)) {
281
304
  throw failure('history-snapshot-protected', 'history snapshot protects a recycled session', 409);
282
305
  }
283
306
  try { await snapshotStore.remove(snapshotId); }
284
307
  catch { throw failure('history-delete-failed', 'history snapshot could not be deleted', 500); }
285
308
  invalidate();
286
- return { deleted: [snapshotId], freedBytes: version.totalBytes };
309
+ return { deleted: [snapshotId], freedBytes: version?.totalBytes ?? 0 };
287
310
  });
288
311
  }
289
312
 
@@ -292,6 +315,7 @@ export function createHistoryService({
292
315
  return lifecycle.run(async () => {
293
316
  invalidate();
294
317
  const inventory = await compute();
318
+ const protectedIds = await protectedSnapshots();
295
319
  const deleted = [];
296
320
  const skipped = [];
297
321
  const failed = [];
@@ -315,7 +339,20 @@ export function createHistoryService({
315
339
  }
316
340
  }
317
341
  for (const item of inventory.degraded) {
318
- skipped.push({ snapshotId: item.snapshotId, reason: 'history-snapshot-degraded' });
342
+ if (protectedIds.has(item.snapshotId)) {
343
+ skipped.push({ snapshotId: item.snapshotId, reason: 'history-snapshot-protected' });
344
+ continue;
345
+ }
346
+ // Unreadable bytes still occupy the store: clearing history reclaims them.
347
+ try {
348
+ await snapshotStore.remove(item.snapshotId);
349
+ deleted.push(item.snapshotId);
350
+ } catch (error) {
351
+ failed.push({
352
+ snapshotId: item.snapshotId,
353
+ reason: typeof error?.code === 'string' && error.code !== '' ? error.code : 'history-delete-failed',
354
+ });
355
+ }
319
356
  }
320
357
  invalidate();
321
358
  return { deleted, freedBytes, skipped, failed };
package/lib/index.js CHANGED
@@ -54,14 +54,14 @@
54
54
  * original conservative behavior: the agent is left parked (cancelled,
55
55
  * flushed) and the id is recorded in a small pending-deletions store while
56
56
  * KEEPING it archived so it stays invisible. The boot sweep
57
- * (`sweepPendingDeletions`, launched once when the plugin's three services
58
- * have all bound) completes the removal through the ordinary cold delete path
59
- * on the next boot when every session is cold. The pending store also
60
- * brackets the successful live path (recorded before disposal, cleared after
61
- * the files are gone), so a crash mid-delete is swept on the next boot
62
- * instead of leaving a half-deleted ghost. Unarchiving a parked session drops
63
- * it from the pending store, and the sweep skips ids that are no longer
64
- * archived, so an unarchive always wins over a parked deletion.
57
+ * (`recycleService.recoverStartup`, launched once when the plugin's three
58
+ * services have all bound) migrates those ids into the recoverable recycle
59
+ * catalog and retries only records carrying durable `purge-pending` intent.
60
+ * The pending store also brackets the successful live path (recorded before
61
+ * disposal, cleared after the files are gone), so a crash mid-delete is swept
62
+ * on the next boot instead of leaving a half-deleted ghost. Unarchiving a
63
+ * parked session drops it from the pending store, and the sweep skips ids that
64
+ * are no longer archived, so an unarchive always wins over a parked deletion.
65
65
  *
66
66
  * Routes bind lazily (same posture as dsh-agent-teams): the web server, the
67
67
  * workspace registry, and session persistence may mount after this plugin
@@ -73,7 +73,7 @@
73
73
  import { lstat, readFile, rm } from 'node:fs/promises';
74
74
  import { readFileSync } from 'node:fs';
75
75
  import { randomUUID } from 'node:crypto';
76
- import { dirname, join } from 'node:path';
76
+ import { basename, dirname, join } from 'node:path';
77
77
  import { homedir } from 'node:os';
78
78
  import { createMetadataStore, MetadataStoreError } from './metadata.js';
79
79
  import { createStatsService } from './stats.js';
@@ -111,6 +111,13 @@ const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
111
111
  /** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
112
112
  const GUARD_HEADER = 'x-dsh-archived-chats';
113
113
 
114
+ /**
115
+ * Windows keeps a deleted directory entry alive while any handle is open (an
116
+ * indexer or antivirus scan is enough), surfacing as EBUSY/EPERM/ENOTEMPTY.
117
+ * Retrying is the documented remedy and is harmless on POSIX.
118
+ */
119
+ const RM_RETRY = Object.freeze({ maxRetries: 5, retryDelay: 50 });
120
+
114
121
  function readPluginVersion() {
115
122
  const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
116
123
  if (typeof manifest.version !== 'string' || manifest.version === '') {
@@ -511,7 +518,10 @@ async function resolveLineageContextTitles(graph, persistence) {
511
518
  * Delete and unarchive invalidate their ids.
512
519
  */
513
520
  async function listArchived(ctx, registry, persistence, titleCache, metadataStore, pendingStore, trashStore) {
514
- const archivedIds = registry.archivedSessionIds.map(String);
521
+ // The archive set is Host state. A repeated id there is meaningless to every
522
+ // consumer here, and would otherwise list one chat twice or fail a reader
523
+ // that treats its own row set as unique.
524
+ const archivedIds = [...new Set(registry.archivedSessionIds.map(String))];
515
525
  if (archivedIds.length === 0) {
516
526
  const metadata = await metadataStore.getMany([]);
517
527
  const trash = await trashStore.load();
@@ -572,6 +582,20 @@ async function listArchived(ctx, registry, persistence, titleCache, metadataStor
572
582
  };
573
583
  }
574
584
 
585
+ /**
586
+ * Read one trash record, failing CLOSED when the store is unreadable. A
587
+ * corrupt trash document must never let a mutation treat a recycled session as
588
+ * an ordinary archived one — the recycle record would survive the change and a
589
+ * later purge would delete a chat the user had put back into service.
590
+ */
591
+ async function requireTrashRecord(trashStore, id) {
592
+ const trash = await trashStore.load();
593
+ if (trash.status !== 'ready') {
594
+ throw Object.assign(new Error('trash store is unavailable'), { code: 'trash-store-unavailable', status: 503 });
595
+ }
596
+ return trash.records.get(String(id)) ?? null;
597
+ }
598
+
575
599
  async function isVisibleArchivedSession(registry, pendingStore, trashStore, id, scope = 'archive') {
576
600
  const key = String(id);
577
601
  if (!registry.archivedSessionIds.map(String).includes(key)) return false;
@@ -746,7 +770,14 @@ async function deleteSession(ctx, registry, persistence, id, titleCache, statsSe
746
770
  const location = await persistence.locate(header);
747
771
  if (typeof location?.path !== 'string') throw Object.assign(new Error('session location unavailable'), { code: 'session-location-unavailable' });
748
772
  const sessionDirectory = dirname(location.path);
749
- await rm(sessionDirectory, { recursive: true, force: true });
773
+ // The only recursive delete in this plugin. Confirm the directory is
774
+ // the session's own before removing it, the same invariant the two
775
+ // restore paths apply: on a host layout that keeps logs as flat
776
+ // files this would otherwise remove the whole session root.
777
+ if (basename(sessionDirectory) !== String(id)) {
778
+ throw Object.assign(new Error('session location is not session-scoped'), { code: 'session-location-unsafe' });
779
+ }
780
+ await rm(sessionDirectory, { recursive: true, force: true, ...RM_RETRY });
750
781
  try {
751
782
  await lstat(sessionDirectory);
752
783
  throw Object.assign(new Error('session directory still exists'), { code: 'session-delete-unconfirmed' });
@@ -777,31 +808,6 @@ async function deleteSession(ctx, registry, persistence, id, titleCache, statsSe
777
808
  return lockHeld ? commit() : lifecycle.run(commit);
778
809
  }
779
810
 
780
- /**
781
- * Boot-time sweep: every pending-deletion id is cold now (plugin activation
782
- * precedes any client resume), so each one completes the ordinary delete
783
- * path. Ids that fail stay in the store for the next boot.
784
- */
785
- async function sweepPendingDeletions(ctx, registry, persistence, statsService, metadataStore, pendingStore, lifecycle) {
786
- let pending;
787
- try {
788
- pending = await pendingStore.load();
789
- } catch (error) {
790
- ctx.logger.warn(`archived-chats: pending-deletions store unreadable: ${String(error)}`);
791
- return;
792
- }
793
- if (pending.size === 0) return;
794
- for (const id of [...pending]) {
795
- try {
796
- const outcome = await deleteSession(ctx, registry, persistence, id, undefined, statsService, metadataStore, pendingStore, lifecycle, true);
797
- if (outcome === 'deleted') ctx.logger.info?.(`archived-chats: swept pending deletion ${id}`);
798
- else if (outcome === 'cancelled') ctx.logger.info?.(`archived-chats: cancelled pending deletion ${id}`);
799
- } catch (error) {
800
- ctx.logger.warn(`archived-chats: pending deletion ${id} failed again: ${String(error)}`);
801
- }
802
- }
803
- }
804
-
805
811
  //#endregion
806
812
 
807
813
  //#region routes
@@ -815,7 +821,7 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
815
821
  try { headers = await persistence.list(); } catch { /* Rows remain measurable as unavailable. */ }
816
822
  const headerById = new Map((Array.isArray(headers) ? headers : []).map((header) => [String(header.id), header]));
817
823
  const workspaces = registry.list();
818
- return registry.archivedSessionIds.map(String).filter((id) => !pending.has(id)).map((id) => {
824
+ return [...new Set(registry.archivedSessionIds.map(String))].filter((id) => !pending.has(id)).map((id) => {
819
825
  const record = trash.records.get(id);
820
826
  const header = headerById.get(id) ?? ctx.get('sessions')?.get(id)?.header;
821
827
  const workspace = record?.workspace ?? workspaces.find((item) => workspaceSessionIds(item).includes(id));
@@ -868,7 +874,18 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
868
874
  throw Object.assign(new Error('live session remains parked'), { code: 'session-live-purge-pending' });
869
875
  }
870
876
  const outcome = await deleteSession(ctx, registry, persistence, id, titleCache, statsService, metadataStore, pendingStore, lifecycle, false, true, false);
871
- if (outcome !== 'deleted') throw Object.assign(new Error('physical purge was cancelled'), { code: 'purge-cancelled' });
877
+ if (outcome === 'deleted') return;
878
+ // `cancelled` only means the delete found nothing left to do. When the
879
+ // session is genuinely gone from both persistence and the archive,
880
+ // this is a retry of a purge whose files were already removed and it
881
+ // must complete — otherwise the record strands in purge-pending with
882
+ // no session left to restore.
883
+ const archived = registry.archivedSessionIds.map(String).includes(String(id));
884
+ let present = true;
885
+ try { present = (await persistence.list()).some((header) => String(header.id) === String(id)); }
886
+ catch { present = true; }
887
+ if (archived || present) throw Object.assign(new Error('physical purge was cancelled'), { code: 'purge-cancelled' });
888
+ await pendingStore.remove([id]);
872
889
  },
873
890
  invalidate: invalidateRecycleCaches,
874
891
  logger: ctx.logger,
@@ -1354,12 +1371,19 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1354
1371
  }
1355
1372
  if (aborted) return;
1356
1373
  } catch (error) {
1357
- if (res.headersSent || res.status === 200) {
1374
+ if (res.headersSent) {
1358
1375
  ctx.logger.warn(`archived-chats: export stream failed: ${String(error?.code ?? error?.name ?? 'Error')}`);
1359
1376
  res.destroy?.();
1360
1377
  return;
1361
1378
  }
1362
- const status = error.status ?? (error instanceof SyntaxError || error instanceof TypeError ? 400 : 500);
1379
+ // Selection parsing attaches its own 400/413, so anything arriving
1380
+ // without a status is an internal failure. Reporting those as
1381
+ // `invalid-export-request` blamed the caller for a bug here and
1382
+ // left nothing in the log to diagnose it.
1383
+ const status = error.status ?? 500;
1384
+ if (status >= 500) {
1385
+ ctx.logger.warn(`archived-chats: export failed: ${String(error?.code ?? error?.name ?? 'Error')}`);
1386
+ }
1363
1387
  sendText(res, status, status === 413
1364
1388
  ? 'request-too-large'
1365
1389
  : (status === 400 ? 'invalid-export-request' : 'export-failed'));
@@ -1478,7 +1502,7 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1478
1502
  send(res, 404, { error: 'session-not-archived' });
1479
1503
  return;
1480
1504
  }
1481
- if ((await trashStore.get(body.sessionId)) !== null) {
1505
+ if ((await requireTrashRecord(trashStore, body.sessionId)) !== null) {
1482
1506
  send(res, 409, { error: 'session-in-trash' });
1483
1507
  return;
1484
1508
  }
@@ -1547,12 +1571,12 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1547
1571
  send(res, 400, { error: 'sessionId-required' });
1548
1572
  return;
1549
1573
  }
1550
- if ((await trashStore.get(body.sessionId)) !== null) {
1574
+ if ((await requireTrashRecord(trashStore, body.sessionId)) !== null) {
1551
1575
  send(res, 409, { error: 'session-in-trash' });
1552
1576
  return;
1553
1577
  }
1554
1578
  const archivedSessionIds = await lifecycle.run(async () => {
1555
- if ((await trashStore.get(body.sessionId)) !== null) throw Object.assign(new Error('session is in trash'), { code: 'session-in-trash', status: 409 });
1579
+ if ((await requireTrashRecord(trashStore, body.sessionId)) !== null) throw Object.assign(new Error('session is in trash'), { code: 'session-in-trash', status: 409 });
1556
1580
  const next = await unarchiveIds(registry, [body.sessionId]);
1557
1581
  titleCache.delete(body.sessionId);
1558
1582
  projectedMessages.invalidate([body.sessionId]);
@@ -1561,7 +1585,7 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1561
1585
  });
1562
1586
  send(res, 200, { ok: true, archivedSessionIds });
1563
1587
  } catch (error) {
1564
- send(res, error.status ?? 500, { error: 'unarchive-failed', message: String(error?.message ?? error) });
1588
+ send(res, error.status ?? 500, { error: error.code ?? 'unarchive-failed', message: String(error?.message ?? error) });
1565
1589
  }
1566
1590
  },
1567
1591
  }), 'archived-chats: unarchive route');
package/lib/lineage.js CHANGED
@@ -65,13 +65,25 @@ export function projectLineage({
65
65
  || !(trashRecords instanceof Map) || !(titles instanceof Map) || !Array.isArray(workspaces)
66
66
  || (focusIds !== undefined && (!Array.isArray(focusIds)
67
67
  || focusIds.some((id) => typeof id !== 'string' || id === '')))) throw invalid();
68
- if (headers.length > maxNodes) throw new LineageError('lineage-limit-exceeded', 'lineage exceeds the node limit', 413);
68
+
69
+ /**
70
+ * The node limit bounds the graph this projection RETURNS, not how many
71
+ * sessions the Host happens to store. `focusIds` already narrows the output to
72
+ * the archived and recycled chats plus the context that explains them, so a
73
+ * store of 100,000 sessions with 25 archived projects 25 nodes. Counting the
74
+ * input instead refused that outright, and the measured cost of the wide input
75
+ * it was guarding against is milliseconds.
76
+ */
77
+ const withinLimit = (count) => {
78
+ if (count > maxNodes) throw new LineageError('lineage-limit-exceeded', 'lineage exceeds the node limit', 413);
79
+ return count;
80
+ };
69
81
 
70
82
  const archived = new Set((Array.isArray(archivedIds) ? archivedIds : []).map(String));
71
83
  const trashed = new Set([...trashRecords.keys()].map(String));
72
84
  const workspaceBySession = new Map();
73
85
  for (const workspace of workspaces) {
74
- if (workspace === null || typeof workspace !== 'object') throw invalid();
86
+ if (workspace === null || typeof workspace !== 'object') continue;
75
87
  const safe = {
76
88
  id: typeof workspace.id === 'string' ? workspace.id : null,
77
89
  title: typeof workspace.title === 'string' ? workspace.title : null,
@@ -82,25 +94,24 @@ export function projectLineage({
82
94
  const nodes = new Map();
83
95
  const order = [];
84
96
  for (const header of headers) {
85
- if (header === null || typeof header !== 'object' || Array.isArray(header)
86
- || typeof header.id !== 'string' || header.id === '' || nodes.has(header.id)
87
- || !Number.isSafeInteger(header.createdAt) || header.createdAt < 0
88
- || (header.parentSession !== undefined && (typeof header.parentSession !== 'string' || header.parentSession === ''))
89
- || (header.seedLength !== undefined && (!Number.isSafeInteger(header.seedLength) || header.seedLength < 0))
90
- || (header.origin !== undefined && header.origin !== 'subagent')
91
- || (header.delegationDepth !== undefined && (!Number.isSafeInteger(header.delegationDepth) || header.delegationDepth < 0))) {
92
- throw invalid();
93
- }
97
+ // Headers are the Host's data, so every field here is a forward-compatibility
98
+ // surface: a new `origin` value, an absent timestamp, a changed numeric type.
99
+ // Each one degrades that single node's detail to a value the public
100
+ // LineageNode already allows. Only an unusable identity drops the row, and a
101
+ // duplicate id keeps the first. One unrecognized session must never cost the
102
+ // whole graph this projection was the only surface that failed that way.
103
+ if (header === null || typeof header !== 'object' || Array.isArray(header)) continue;
104
+ if (typeof header.id !== 'string' || header.id === '' || nodes.has(header.id)) continue;
94
105
  const id = header.id;
95
106
  const cachedTitle = titles.get(id);
96
107
  const node = {
97
108
  id,
98
- parentSession: header.parentSession ?? null,
99
- seedLength: header.seedLength ?? null,
100
- origin: header.origin ?? null,
101
- delegationDepth: header.delegationDepth ?? 0,
109
+ parentSession: typeof header.parentSession === 'string' && header.parentSession !== '' ? header.parentSession : null,
110
+ seedLength: Number.isSafeInteger(header.seedLength) && header.seedLength >= 0 ? header.seedLength : null,
111
+ origin: header.origin === 'subagent' ? 'subagent' : null,
112
+ delegationDepth: Number.isSafeInteger(header.delegationDepth) && header.delegationDepth >= 0 ? header.delegationDepth : 0,
102
113
  title: typeof cachedTitle === 'string' ? cachedTitle : (typeof header.title === 'string' ? header.title : null),
103
- createdAt: header.createdAt,
114
+ createdAt: Number.isSafeInteger(header.createdAt) && header.createdAt >= 0 ? header.createdAt : null,
104
115
  workspace: { ...(workspaceBySession.get(id) ?? { id: null, title: null }) },
105
116
  status: trashed.has(id) ? 'trash' : archived.has(id) ? 'archived' : 'active',
106
117
  children: [],
@@ -112,8 +123,7 @@ export function projectLineage({
112
123
  for (const [key, record] of trashRecords) {
113
124
  const id = String(key);
114
125
  if (nodes.has(id)) continue;
115
- if (record === null || typeof record !== 'object' || String(record.sessionId) !== id) throw invalid();
116
- if (nodes.size >= maxNodes) throw new LineageError('lineage-limit-exceeded', 'lineage exceeds the node limit', 413);
126
+ if (record === null || typeof record !== 'object' || String(record.sessionId) !== id) continue;
117
127
  const workspace = record.workspace;
118
128
  nodes.set(id, {
119
129
  id,
@@ -232,6 +242,7 @@ export function projectLineage({
232
242
  current = parent;
233
243
  }
234
244
  }
245
+ withinLimit(included.size);
235
246
  return {
236
247
  roots: cloneForest(roots, included),
237
248
  diagnostics: diagnostics.filter((item) => included.has(item.sessionId)).map((item) => ({ ...item })),
@@ -239,6 +250,7 @@ export function projectLineage({
239
250
  };
240
251
  }
241
252
 
253
+ withinLimit(nodes.size + missingNodes.size);
242
254
  return {
243
255
  roots: cloneForest(roots),
244
256
  diagnostics: diagnostics.map((item) => ({ ...item })),
package/lib/recycle.js CHANGED
@@ -85,8 +85,6 @@ export function createRecycleService({
85
85
  if (typeof persistence?.list !== 'function') throw new TypeError('persistence.list is required');
86
86
  if (typeof trashStore?.load !== 'function' || typeof snapshotStore?.capture !== 'function') throw new TypeError('recycle stores are required');
87
87
  if (typeof lifecycle?.run !== 'function') throw new TypeError('lifecycle.run is required');
88
- void attachments;
89
- void purgePhysical;
90
88
 
91
89
  async function loadedTrash() {
92
90
  const loaded = await trashStore.load();
@@ -313,7 +311,7 @@ export function createRecycleService({
313
311
  if (typeof persistence.create !== 'function' || typeof persistence.append !== 'function' || typeof persistence.locate !== 'function') {
314
312
  throw error('snapshot-restore-unsupported', 'persistence restore capability is unavailable', 501);
315
313
  }
316
- const location = persistence.locate(meta);
314
+ const location = await persistence.locate(meta);
317
315
  if (typeof location?.path !== 'string' || basename(dirname(location.path)) !== id) {
318
316
  throw error('snapshot-restore-unsupported', 'persistence rollback location is unavailable', 501);
319
317
  }
@@ -408,7 +406,7 @@ export function createRecycleService({
408
406
  if (createAttempted) {
409
407
  try {
410
408
  if (typeof persistence.removeSession === 'function') await persistence.removeSession(id);
411
- else await rm(dirname(location.path), { recursive: true, force: true });
409
+ else await rm(dirname(location.path), { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
412
410
  } catch (rollbackError) { rollbackErrors.push(rollbackError); }
413
411
  }
414
412
  if (rollbackErrors.length > 0) {
@@ -463,15 +461,20 @@ export function createRecycleService({
463
461
  if (!['trashed', 'degraded'].includes(record.state)) throw error('trash-state-conflict', 'trash record cannot be purged', 409);
464
462
  record = await trashStore.transition(id, 'purge-pending', { purgeRequestedAt: now().toISOString() });
465
463
  }
464
+ // Order matters. Capabilities are checked first, then the snapshots are
465
+ // removed, and only then the session log — the irreversible step. Anything
466
+ // that fails before `purgePhysical` leaves the original session intact, so
467
+ // the record stays completable on the next attempt instead of stranding in
468
+ // purge-pending with its session already gone.
466
469
  if (typeof purgePhysical !== 'function') throw error('purge-unsupported', 'physical purge is unavailable', 501);
467
- await purgePhysical(id, record);
468
470
  if (typeof snapshotStore.removeForSession !== 'function' || typeof snapshotStore.latestFor !== 'function') {
469
471
  throw error('purge-unsupported', 'snapshot purge verification is unavailable', 501);
470
472
  }
471
- await snapshotStore.removeForSession(id);
473
+ await snapshotStore.removeForSession(id, { knownIds: [record.snapshotId] });
472
474
  if (await snapshotStore.latestFor(id) !== null) {
473
475
  throw error('snapshot-delete-unconfirmed', 'session snapshots still exist');
474
476
  }
477
+ await purgePhysical(id, record);
475
478
  const removed = await trashStore.remove(id);
476
479
  if (!removed.includes(id)) throw error('trash-state-conflict', 'trash record changed during purge', 409);
477
480
  invalidate?.([id]);
package/lib/restore.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { basename, dirname, join } from 'node:path';
3
+
4
+ /** Events appended per call, matching the History restore writer. */
5
+ const APPEND_BATCH = 500;
3
6
 
4
7
  function unsupported(reason = 'writer-missing') {
5
8
  const error = new Error('restore is unsupported by this Harness host');
@@ -8,13 +11,47 @@ function unsupported(reason = 'writer-missing') {
8
11
  return error;
9
12
  }
10
13
 
14
+ /**
15
+ * Resolve a session writer. A host that exposes a dedicated restore entry point
16
+ * is preferred; otherwise the ordinary create/append/locate surface is used —
17
+ * the same capability History "restore as copy" and recycle snapshot restore
18
+ * already write through, so ZIP import works wherever those work instead of
19
+ * reporting `restore-unsupported` on the very same host.
20
+ */
11
21
  function resolveWriter(persistence) {
12
- if (typeof persistence?.restoreSession === 'function') return persistence.restoreSession.bind(persistence);
13
- if (typeof persistence?.restore === 'function') return persistence.restore.bind(persistence);
14
- if (typeof persistence?.importSession === 'function') return persistence.importSession.bind(persistence);
22
+ if (typeof persistence?.restoreSession === 'function') return { kind: 'native', write: persistence.restoreSession.bind(persistence) };
23
+ if (typeof persistence?.restore === 'function') return { kind: 'native', write: persistence.restore.bind(persistence) };
24
+ if (typeof persistence?.importSession === 'function') return { kind: 'native', write: persistence.importSession.bind(persistence) };
25
+ if (typeof persistence?.create === 'function'
26
+ && typeof persistence?.append === 'function'
27
+ && typeof persistence?.locate === 'function') {
28
+ return { kind: 'append', write: (payload) => appendWrite(persistence, payload) };
29
+ }
15
30
  return null;
16
31
  }
17
32
 
33
+ /**
34
+ * Write one session through create + append and return an undo that removes it.
35
+ * The destination is confirmed to be session-scoped before anything is created,
36
+ * so the fallback rollback can never delete more than this session.
37
+ */
38
+ async function appendWrite(persistence, payload) {
39
+ const meta = structuredClone(payload.meta);
40
+ const location = await persistence.locate(meta);
41
+ if (typeof location?.path !== 'string' || basename(dirname(location.path)) !== String(payload.id)) {
42
+ throw unsupported('rollback-location-missing');
43
+ }
44
+ await persistence.create(meta);
45
+ const events = payload.events;
46
+ for (let offset = 0; offset < events.length; offset += APPEND_BATCH) {
47
+ await persistence.append(payload.id, structuredClone(events.slice(offset, offset + APPEND_BATCH)));
48
+ }
49
+ return async () => {
50
+ if (typeof persistence.removeSession === 'function') { await persistence.removeSession(payload.id); return; }
51
+ await rm(dirname(location.path), { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
52
+ };
53
+ }
54
+
18
55
  function resolveRemove(persistence) {
19
56
  const remove = persistence?.removeSession ?? persistence?.deleteSession ?? persistence?.remove;
20
57
  return typeof remove === 'function' ? remove.bind(persistence) : null;
@@ -51,7 +88,9 @@ export function createRestoreAdapter({ ctx, persistence, registry, metadataStore
51
88
  const remover = resolveRemove(persistence);
52
89
  let capability = { supported: true };
53
90
  if (writer === null) capability = { supported: false, reason: 'writer-missing' };
54
- else if (remover === null) capability = { supported: false, reason: 'rollback-missing' };
91
+ // The append writer carries its own session-scoped rollback, so a dedicated
92
+ // remover is only required for the native restore entry points.
93
+ else if (remover === null && writer.kind !== 'append') capability = { supported: false, reason: 'rollback-missing' };
55
94
  else if (typeof registry?.setState !== 'function' || registry.state === undefined) capability = { supported: false, reason: 'registry-writer-missing' };
56
95
  else if (typeof metadataStore?.getMany !== 'function' || typeof metadataStore?.set !== 'function' || typeof metadataStore?.remove !== 'function') capability = { supported: false, reason: 'metadata-writer-missing' };
57
96
 
@@ -92,8 +131,10 @@ export function createRestoreAdapter({ ctx, persistence, registry, metadataStore
92
131
  await writeFile(path, `${JSON.stringify(item.record)}\n`, { encoding: 'utf8', mode: 0o600 });
93
132
  if (typeof persistence.inspect === 'function') {
94
133
  // The host inspection is a capability check only; it does not replace
95
- // the staged record or mutate any existing session.
96
- await Promise.resolve(persistence.inspect(item.id, { staged: item.record }));
134
+ // the staged record or mutate any existing session. The id does not
135
+ // exist yet, so a reader that fails closed on unknown sessions is the
136
+ // expected answer and must not abort the restore.
137
+ await Promise.resolve(persistence.inspect(item.id, { staged: item.record })).catch(() => undefined);
97
138
  }
98
139
  staged.push(item);
99
140
  } catch (error) {
@@ -141,9 +182,9 @@ export function createRestoreAdapter({ ctx, persistence, registry, metadataStore
141
182
  events: item.record.source.events,
142
183
  archive: item.record.archive,
143
184
  };
144
- const fallbackUndo = () => remover(item.id);
185
+ const fallbackUndo = remover === null ? () => undefined : () => remover(item.id);
145
186
  transaction._undos.push(fallbackUndo);
146
- const result = await writer(payload);
187
+ const result = await writer.write(payload);
147
188
  transaction._undos[transaction._undos.length - 1] = resolveUndo(result, fallbackUndo);
148
189
  const workspace = workspaceFor(registry, item.workspace?.id);
149
190
  if (workspace === null) {
package/lib/search.js CHANGED
@@ -398,11 +398,34 @@ function searchSessionLimit(value) {
398
398
  return value;
399
399
  }
400
400
 
401
+ /**
402
+ * Map one offset in the normalized text back to its source code-point index.
403
+ * Only needed when NFKC changed the text length (fi → fi, ⑴ → (1), ㎡ → m2):
404
+ * a length-preserving mapping keeps offsets aligned already.
405
+ */
406
+ function sourcePointOffset(points, offset) {
407
+ let consumed = 0;
408
+ for (let index = 0; index < points.length; index += 1) {
409
+ const next = consumed + normalizeSearchText(points[index]).length;
410
+ if (offset < next) return index;
411
+ consumed = next;
412
+ }
413
+ return points.length;
414
+ }
415
+
401
416
  function excerptFor(message, terms) {
402
417
  const points = codePoints(message.searchable.replace(/\s+/gu, ' ').trim());
403
- const normalized = normalizeSearchText(points.join(''));
404
- const firstUtf16 = terms.map((term) => normalized.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? 0;
405
- const first = codePoints(normalized.slice(0, firstUtf16)).length;
418
+ const text = points.join('');
419
+ const normalized = normalizeSearchText(text);
420
+ const matched = terms.map((term) => normalized.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0];
421
+ // A normalized offset is a valid source offset only while normalization
422
+ // preserved length; otherwise the window has to be mapped back explicitly or
423
+ // the excerpt lands somewhere that does not contain the match at all.
424
+ const first = matched === undefined
425
+ ? 0
426
+ : (normalized.length === text.length
427
+ ? codePoints(text.slice(0, matched)).length
428
+ : sourcePointOffset(points, matched));
406
429
  const radius = Math.floor(SEARCH_LIMITS.excerptCodePoints / 2);
407
430
  const start = Math.max(0, first - radius);
408
431
  const end = Math.min(points.length, start + SEARCH_LIMITS.excerptCodePoints);
package/lib/snapshot.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { relative, resolve } from 'node:path';
4
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
5
5
  import { findProjectedImage, paginateProjectedMessages, projectArchivedMessages } from './search.js';
6
6
  import { syncDirectory } from './durable.js';
7
7
 
@@ -16,6 +16,13 @@ export const SNAPSHOT_LIMITS = Object.freeze({
16
16
 
17
17
  export const HISTORY_SNAPSHOT_LIMIT = 5000;
18
18
 
19
+ /**
20
+ * Windows keeps a deleted directory entry alive while any handle is open (an
21
+ * indexer or antivirus scan is enough), surfacing as EBUSY/EPERM/ENOTEMPTY.
22
+ * Retrying is the documented remedy and is harmless elsewhere.
23
+ */
24
+ const RM_RETRY = Object.freeze({ maxRetries: 5, retryDelay: 50 });
25
+
19
26
  const SNAPSHOT_FORMAT = 'dsh-archived-chats/snapshot';
20
27
  const SESSION_FORMAT = 'dsh-archived-chats/snapshot-session';
21
28
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -144,9 +151,15 @@ function mediaExtension(mediaType) {
144
151
  return subtype || 'bin';
145
152
  }
146
153
 
154
+ /**
155
+ * Containment test that holds on both separators. `relative()` answers with the
156
+ * platform separator, so a POSIX-only `'../'` check misses `'..\\'` on Windows;
157
+ * an absolute answer means a different drive or root entirely.
158
+ */
147
159
  function isWithin(root, candidate) {
148
160
  const path = relative(root, candidate);
149
- return path === '' || (!path.startsWith('..') && !path.includes('../'));
161
+ if (path === '') return true;
162
+ return !isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`);
150
163
  }
151
164
 
152
165
  function validSnapshotId(snapshotId) {
@@ -397,7 +410,7 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
397
410
  if (error?.code !== 'ENOENT') throw error;
398
411
  }
399
412
  for (let attempt = 0; attempt < SNAPSHOT_LIMITS.maxRevisionAttempts; attempt += 1) {
400
- await rm(staging, { recursive: true, force: true });
413
+ await rm(staging, { recursive: true, force: true, ...RM_RETRY });
401
414
  const before = await sourceRevision(sessionId);
402
415
  let inspected;
403
416
  try { inspected = await persistence.inspect(sessionId); }
@@ -468,7 +481,14 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
468
481
  await syncDirectory(staging);
469
482
  try { await rename(staging, destination); }
470
483
  catch (error) {
471
- if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') throw failure('snapshot-conflict', 'snapshot id already exists', 409);
484
+ // Windows reports a rename onto an existing directory as EPERM/EACCES
485
+ // rather than EEXIST/ENOTEMPTY, so confirm what is actually there.
486
+ if (['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error?.code)) {
487
+ let occupied = false;
488
+ try { occupied = (await lstat(destination)) !== undefined; }
489
+ catch (probe) { if (probe?.code !== 'ENOENT') throw error; }
490
+ if (occupied) throw failure('snapshot-conflict', 'snapshot id already exists', 409);
491
+ }
472
492
  throw error;
473
493
  }
474
494
  await syncDirectory(canonicalRoot);
@@ -476,7 +496,7 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
476
496
  }
477
497
  throw failure('snapshot-source-busy', 'snapshot source did not stabilize', 409);
478
498
  } finally {
479
- await rm(staging, { recursive: true, force: true }).catch(() => undefined);
499
+ await rm(staging, { recursive: true, force: true, ...RM_RETRY }).catch(() => undefined);
480
500
  }
481
501
  }
482
502
 
@@ -684,7 +704,7 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
684
704
  const path = resolve(staging, entry);
685
705
  try {
686
706
  const details = await lstat(path);
687
- if (details.mtimeMs < storeStartedAt) await rm(path, { recursive: true, force: true });
707
+ if (details.mtimeMs < storeStartedAt) await rm(path, { recursive: true, force: true, ...RM_RETRY });
688
708
  } catch (error) {
689
709
  if (error?.code !== 'ENOENT') throw error;
690
710
  }
@@ -720,7 +740,7 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
720
740
  async function remove(snapshotId) {
721
741
  const canonicalRoot = await ensureRoot();
722
742
  const directory = await snapshotPath(snapshotId, canonicalRoot);
723
- await rm(directory, { recursive: true, force: true });
743
+ await rm(directory, { recursive: true, force: true, ...RM_RETRY });
724
744
  try {
725
745
  await lstat(directory);
726
746
  throw failure('snapshot-delete-unconfirmed', 'snapshot directory still exists');
@@ -730,13 +750,39 @@ export function createSnapshotStore({ root, persistence, attachments, now = () =
730
750
  await syncDirectory(canonicalRoot);
731
751
  }
732
752
 
733
- async function removeForSession(sessionId) {
753
+ /**
754
+ * Read one published snapshot's owning session id without validating its
755
+ * contents, so a degraded snapshot can still be attributed to the session it
756
+ * belongs to. Returns null when even the manifest identity is unreadable.
757
+ */
758
+ async function snapshotOwner(snapshotId, canonicalRoot) {
759
+ try {
760
+ const directory = await snapshotPath(snapshotId, canonicalRoot);
761
+ const bytes = await readBounded(resolve(directory, 'manifest.json'), SNAPSHOT_LIMITS.maxManifestBytes);
762
+ const value = parseJson(bytes, 'manifest.json');
763
+ return plainObject(value) && typeof value.sessionId === 'string' && value.sessionId !== '' ? value.sessionId : null;
764
+ } catch { return null; }
765
+ }
766
+
767
+ /**
768
+ * Remove every published snapshot belonging to one session. A snapshot that
769
+ * fails validation is still removed when its manifest identity attributes it
770
+ * to this session, and an unrelated degraded snapshot is skipped instead of
771
+ * aborting the sweep — physical purge must never be blocked by corruption
772
+ * somewhere else in the store. `knownIds` covers records whose snapshot is
773
+ * damaged past attribution.
774
+ */
775
+ async function removeForSession(sessionId, { knownIds = [] } = {}) {
734
776
  if (typeof sessionId !== 'string' || sessionId === '') return [];
735
777
  const canonicalRoot = await ensureRoot();
736
778
  const removed = [];
779
+ const claimed = new Set((Array.isArray(knownIds) ? knownIds : [knownIds])
780
+ .filter((id) => typeof id === 'string' && UUID.test(id)));
737
781
  for (const entry of await publishedEntries(canonicalRoot)) {
738
- const checked = await validate(entry.name);
739
- if (checked.manifest.sessionId !== sessionId) continue;
782
+ let owner;
783
+ try { owner = (await validate(entry.name)).manifest.sessionId; }
784
+ catch { owner = await snapshotOwner(entry.name, canonicalRoot); }
785
+ if (owner !== sessionId && !claimed.has(entry.name)) continue;
740
786
  await remove(entry.name);
741
787
  removed.push(entry.name);
742
788
  }
package/lib/stats.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { lstat, readdir, realpath } from 'node:fs/promises';
2
- import { dirname, join, relative, sep } from 'node:path';
2
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
3
3
 
4
+ // An absolute answer from relative() means a different drive or root, which
5
+ // Windows can produce and a separator check alone would accept.
4
6
  const isWithin = (root, path) => {
5
7
  const pathFromRoot = relative(root, path);
6
- return pathFromRoot === '' || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== '..' && !pathFromRoot.startsWith(sep));
8
+ return pathFromRoot === ''
9
+ || (!isAbsolute(pathFromRoot) && pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`));
7
10
  };
8
11
 
9
12
  export async function measureDirectory(root) {
@@ -16,10 +16,16 @@
16
16
  * attachment descriptors, and
17
17
  * degrades only image loading when the optional attachment service is absent.
18
18
  * Metadata mutation remains guarded through `/metadata`, and archive restore is
19
- * preview-first through the import routes. Import restore is capability detected
20
- * and returns `restore-unsupported` when the host has no writer. Ordinary
21
- * delete creates a verified local protection snapshot and moves the session
22
- * into the recycle catalog; only guarded trash purge physically deletes it.
19
+ * preview-first through the import routes. Import restore is capability
20
+ * detected: a dedicated host restore entry point is preferred, otherwise the
21
+ * ordinary create/append/locate surface is used, and `restore-unsupported` is
22
+ * returned only when neither exists. Archive mutations fail closed while the
23
+ * recycle catalog is unreadable, and `/state` reports `trashStatus` so the
24
+ * listing can be labelled as unverified. Ordinary delete creates a verified
25
+ * local protection snapshot and moves the session into the recycle catalog;
26
+ * only guarded trash purge physically deletes it, removing protection
27
+ * snapshots before the irreversible session delete so a failure never strands
28
+ * a record whose session is already gone.
23
29
  * Startup migrates legacy pending deletions into recoverable trash and retries
24
30
  * only records carrying durable `purge-pending` intent.
25
31
  */
@@ -62,6 +68,7 @@ export interface HistoryRestoreResult {
62
68
  warnings: Array<{ id: string; reason: string }>;
63
69
  }
64
70
 
71
+ /** A degraded version carries no readable bytes but can still be reclaimed. */
65
72
  export interface HistoryDeleteResult {
66
73
  deleted: string[];
67
74
  freedBytes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "DeepSeek Harness 会话档案:按工作区浏览和全文搜索归档聊天,原生只读预览对话、工具活动和已存储图片,管理标签备注与 ZIP 备份恢复;保留历史版本并恢复为副本,提供可撤销回收站、空间分账、保留策略和来源与分支。所有数据留在本机。 Session Archive for DeepSeek Harness: browse and full-text search archived chats; natively preview conversations, tool activity, and stored images read-only; manage tags, notes, and ZIP backup restore; retain local History with restore-as-copy, an undoable Recycle Bin, storage accounting, retention policies, and Origins & Branches. All data stays local.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",