@xrn1997/dsh-session-delete 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +124 -55
  2. package/lib/client.js +1429 -427
  3. package/lib/index.js +830 -183
  4. package/lib/types/api/dispatch.d.ts +55 -0
  5. package/lib/types/client/batch-bar.d.ts +24 -0
  6. package/lib/types/client/batch-confirm.d.ts +31 -0
  7. package/lib/types/client/batch-menu-item.d.ts +19 -0
  8. package/lib/types/client/batch-select.d.ts +97 -0
  9. package/lib/types/client/context.d.ts +31 -0
  10. package/lib/types/client/copy.d.ts +168 -0
  11. package/lib/types/client/delete-confirm.d.ts +40 -0
  12. package/lib/types/client/delete-menu-item.d.ts +20 -0
  13. package/lib/types/client/index.d.ts +19 -0
  14. package/lib/types/client/markdown.d.ts +11 -0
  15. package/lib/types/client/remote.d.ts +181 -0
  16. package/lib/types/client/trash-detail.d.ts +27 -0
  17. package/lib/types/client/trash-entry.d.ts +6 -0
  18. package/lib/types/client/trash-panel.d.ts +10 -0
  19. package/lib/types/client/trash-store.d.ts +4 -0
  20. package/lib/types/client/ui/Button.d.ts +21 -0
  21. package/lib/types/client/ui/Checkbox.d.ts +29 -0
  22. package/lib/types/client/ui/MenuItemButton.d.ts +32 -0
  23. package/lib/types/client/ui/Modal.d.ts +35 -0
  24. package/lib/types/client/ui/Tag.d.ts +16 -0
  25. package/lib/types/client/ui/Toast.d.ts +36 -0
  26. package/lib/types/client/ui/Tooltip.d.ts +37 -0
  27. package/lib/types/client/ui/format.d.ts +18 -0
  28. package/lib/types/client/ui/icons.d.ts +33 -0
  29. package/lib/types/client/ui/recipe.d.ts +11 -0
  30. package/lib/types/client/ui/styles.d.ts +2 -0
  31. package/lib/types/client/undo-toast.d.ts +49 -0
  32. package/lib/types/client/use-column-resize.d.ts +62 -0
  33. package/lib/types/client/use-panel-width.d.ts +15 -0
  34. package/lib/{index.d.ts → types/index.d.ts} +9 -36
  35. package/lib/types/log/session-log.d.ts +40 -0
  36. package/lib/types/log/zstd-frames.d.ts +30 -0
  37. package/lib/types/ports/fs-port.d.ts +16 -0
  38. package/lib/types/ports/host-port.d.ts +97 -0
  39. package/lib/types/remote/map-error.d.ts +19 -0
  40. package/lib/types/shared/wire.d.ts +134 -0
  41. package/lib/types/trash/detail.d.ts +40 -0
  42. package/lib/types/trash/entry.d.ts +35 -0
  43. package/lib/types/trash/manifest.d.ts +18 -0
  44. package/lib/types/trash/reconcile.d.ts +35 -0
  45. package/lib/types/verbs/apply.d.ts +3 -0
  46. package/lib/types/verbs/delete-session.d.ts +30 -0
  47. package/lib/types/verbs/errors.d.ts +20 -0
  48. package/lib/types/verbs/purge-trash.d.ts +6 -0
  49. package/lib/types/verbs/restore-session.d.ts +37 -0
  50. package/package.json +105 -95
  51. package/lib/client.js.map +0 -1
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 观测**面板自己那一块**的宽度(回收站的断点全靠它,设计稿 2026-10-15 §2.1)。
3
+ *
4
+ * 为什么不是 `window.innerWidth`:这块住在宿主的中央列里,**侧栏折叠/展开、拖动分栏**都会改它的宽度
5
+ * 而 window 一点不动——只听 window 的 resize 会在侧栏折叠后拿着旧宽度画错形态。
6
+ *
7
+ * 量的是**盒子的 border-box 宽**(`getBoundingClientRect().width`),不是 `contentRect.width`:
8
+ * 后者已经扣掉内边距,而"多宽才分两栏"那个阈值说的是整块盒子——拿扣过内边垫的数去比就是**扣两次**,
9
+ * 结果明明够宽却退化成下钻(2026-10-15 真机反馈后定的判据)。
10
+ *
11
+ * 首帧读一次当前宽度,之后跟着 RO 走;读到 0 就是窄档 ⇒ **窄档是默认、宽档要被证明**。
12
+ * RO 缺席(不可能的宿主)时停在窄档:功能一条不少,只是同屏两栏变成下钻。
13
+ */
14
+ import { type RefObject } from 'react';
15
+ export declare function usePanelWidth(ref: RefObject<HTMLElement | null>): number;
@@ -1,35 +1,5 @@
1
- import { Context } from "@deepseek-ai/cordis";
2
- //#region src/trash/entry.d.ts
3
- /** 回收站的一条记录。`id` 由 `makeEntryId` 生成(`<删除时刻>-<原目录名>`)——**它不是随手起的键**:
4
- * 回收站目录名就是它,恢复也按它反推出原目录名放回(设计稿 §3),所以 id 的形状有契约意义。
5
- * 其余字段在删除那一刻就地取。 */
6
- interface TrashEntry {
7
- id: string;
8
- sessionId: string;
9
- projectDir: string;
10
- /** 删除时它所属的工作区;**空串 = 删除时就没有账目**(「未分组」的会话,宿主的"删除工作区"会造出这种),
11
- * 恢复时据此跳过 `attach`。 */
12
- workspaceId: string;
13
- /** 删除**之前**的归档态。删除会把"原本未归档"的会话归档(藏起来,免得它掉进「未分组」),
14
- * 恢复时按这个字段还原可见性:true ⇒ 仍处归档态,false ⇒ 取消归档放回树里。 */
15
- wasArchived: boolean;
16
- deletedAt: number;
17
- /** 删除时**尽力**取的会话标题(来源:`storages/session_projcache/sessions/<会话id>.json`
18
- * 里的 `rows.title.val`);取不到存空串,绝不因此让删除失败。 */
19
- title: string;
20
- /** 删除时用 `fs.sizeOfDir` 量到的会话目录字节数。 */
21
- sizeBytes: number;
22
- }
23
- //#endregion
24
- //#region src/trash/manifest.d.ts
25
- /** 回收站清单的**唯一**读法(动词只依赖这个端口,不认 storage domain 的形状)。 */
26
- interface ManifestPort {
27
- list(): Promise<TrashEntry[]>;
28
- add(e: TrashEntry): Promise<void>;
29
- remove(id: string): Promise<void>;
30
- }
31
- //#endregion
32
- //#region src/index.d.ts
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type ManifestPort } from './trash/manifest.js';
33
3
  export declare const name = "@xrn1997/dsh-session-delete";
34
4
  /**
35
5
  * 插件依赖:**只写字符串数组**(隔离数据根上实测:写成 `{ required, optional }` 会让插件停在
@@ -43,15 +13,19 @@ export declare const name = "@xrn1997/dsh-session-delete";
43
13
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
44
14
  *
45
15
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
46
- * 前缀路由,浏览器半同源 fetch 那五个端点。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
16
+ * 前缀路由,浏览器半同源 fetch 那五个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
47
17
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
48
18
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
49
19
  */
50
20
  export declare const inject: string[];
51
21
  /**
52
22
  * 清单的真实绑定:官方 storage domain → `ManifestPort`(`StorageDomainLike` 在动词层保持一种读法)。
53
- * 域**懒开**且只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
54
- * 插件卸载时关掉句柄(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约)。
23
+ * 域**懒开**且成功句柄只开一次(`apply` 是同步的,而 `open()` 是异步的),并挂一条 `ctx.effect` 清理:
24
+ * 插件卸载时关掉句柄并把 `close()` 的落地交回框架(句柄归调用方所有,这是 `open()` 的 JSDoc 明写的契约;
25
+ * 2026-10-17 起不再 `void` 走掉——没关严的句柄会让下一份同名域撞 `already-open`)。
26
+ *
27
+ * **失败不缓存**:`open()` 被拒是瞬态(域被别的持有者占着、装载竞态),把第一次的 rejected promise
28
+ * 缓存起来等于"这个会话永远删不掉"——下一次读会重新走 `open()`,成功才落下活句柄。
55
29
  */
56
30
  export declare function createStorageManifest(ctx: Context): ManifestPort;
57
31
  /**
@@ -62,4 +36,3 @@ export declare function createStorageManifest(ctx: Context): ManifestPort;
62
36
  * 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
63
37
  */
64
38
  export declare function apply(ctx: Context): void;
65
- //#endregion
@@ -0,0 +1,40 @@
1
+ /** 一行 JSONL 的最小面:`type` / `time` 之外都留在 `data`(或记录根)上,由调用方按 `type` 自己判。 */
2
+ export interface SessionRecord {
3
+ type?: string;
4
+ time?: number;
5
+ /** `session` 那一行把这三个成员挂在**记录根**上(实测逐字:`{"type":"session","version":4,"id":…,"createdAt":…,"cwd":"…"}`),所以这里镜像出来。 */
6
+ id?: string;
7
+ createdAt?: number;
8
+ cwd?: string;
9
+ data?: unknown;
10
+ }
11
+ export interface SessionLogStats {
12
+ frames: number;
13
+ records: number;
14
+ /** 解不开 JSON 的行数(多半就是被切的那半行)。 */
15
+ unparsedLines: number;
16
+ /** 整条日志最后一个换行之后的字节数(>0 说明尾巴被切了一刀)。 */
17
+ truncatedTailBytes: number;
18
+ }
19
+ /**
20
+ * 同一个会话目录里**可以并存多代日志**(宿主换代时把老代留在原地当历史语料,本机实测 7 个目录如此),
21
+ * 而新代是老代的**迁移后超集**(实测:`firstTs` 与开头那条提问完全相同、记录更多、末尾更晚)
22
+ * ⇒ 要读的是版本号最大的那一代,读旧代 = 读到一份截了尾巴的快照;把几代拼起来 = 重复计数。
23
+ *
24
+ * 明文(无 `.zstd`)不在此列:本插件的读法逐帧解 zstd,明文得走另一条解码路。
25
+ */
26
+ export declare function pickSessionLog(names: readonly string[]): string | undefined;
27
+ /**
28
+ * 逐帧走一遍日志,每条记录交给回调。
29
+ *
30
+ * 回调返回 `false` = **提前停机**(后面的帧不再解——`readSessionHeader` 只要第一行)。
31
+ * 停机后 `stats` 只是停机前那部分,别拿来做全量对账。
32
+ */
33
+ export declare function walkSessionLog(raw: Buffer, onRecord: (record: SessionRecord) => void | false): SessionLogStats;
34
+ /**
35
+ * 日志的 **header**:格式事实是"第一行即 header"(`type: 'session'`,含 `id` 与 `cwd`,v3/v4 均如此,
36
+ * AGENTS.md 有实测背书)——这里按 `type === 'session'` 找**第一条**这样的记录并就地停机
37
+ * (不要求它恰是第一个可解析行:坏一行前缀不该让整条日志判不出身份)。
38
+ * 找不到(空日志 / 全是别种记录)回 `undefined`;帧坏了照抛——那是"读不出来",与"没有 header"是两件事。
39
+ */
40
+ export declare function readSessionHeader(raw: Buffer): SessionRecord | undefined;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 多帧 zstd 流的**帧边界**(设计稿 2026-10-15 §1.1)。
3
+ *
4
+ * 为什么要有这个文件:会话日志是一次次 append 出来的**帧序列**,而 Node 的 `zstdDecompressSync`
5
+ * **与** `createZstdDecompress()` 流式**都只给第一帧**(实测:本机一个 3.8 MB 的文件,两个 API 都只回
6
+ * 209 字节 = header 那一行)。要拿到全部记录就必须自己把帧切开。
7
+ *
8
+ * 帧 = `magic(4) + Frame_Header_Descriptor(1) + [Window 1B 若 !single] + [DID 0/1/2/4] + [FCS]
9
+ * + 若干块(3B 块头 + 块体)+ [4B 尾巴]`。
10
+ *
11
+ * **真实产物的形态是量出来的**(本机 125 个文件 / 36 904 帧):descriptor **恒 `0x04`**(单段 ⇒ 无
12
+ * Window,FCS 1 字节,无字典,校验位没置位),块类型只有 `Compressed`(36 630) 与 `Raw`(620),
13
+ * 每帧 **1~120 块**(多数 1 块)——所以多块循环与 Raw 两条路都在生产上跑着,不是纸面分支。
14
+ * 类型 3 是规范的 Reserved 位(不是"未压缩块"):产物里没出现过,Node 也当场拒解 ⇒ 遇到就抛。
15
+ *
16
+ * **尾巴那 4 字节按实测处理,不按 descriptor 的 bit5**:校验位从未置位,却每帧都带 4 字节;
17
+ * 判"读不读这 4 字节"用的是「跳过去必须正好撞上下一帧的 magic 或到 EOF」——猜错不会静默切歪:
18
+ * 末尾的自证与块界护栏会抛(护栏是必需的:块头越界时 `|` 读回 0,而 0 的 `Last_Block` 位没置位,
19
+ * 少了它块循环每圈只 +3 字节却永不 break,整个进程卡死——2026-10-15 写用例时真卡死过一次)。
20
+ */
21
+ export interface FrameRange {
22
+ start: number;
23
+ end: number;
24
+ }
25
+ /**
26
+ * 把 `raw` 切成帧区间。
27
+ * @throws 起始处不是 magic、或某帧算到的末位既不是下一帧帧头也不是 EOF——**一律抛**,
28
+ * 绝不"尽力往下解":切错帧的后果是把半截压缩体当解压输入,那时解出来的东西不可信。
29
+ */
30
+ export declare function frameRanges(raw: Buffer): FrameRange[];
@@ -0,0 +1,16 @@
1
+ export interface FsPort {
2
+ exists(p: string): Promise<boolean>;
3
+ /** 建目录(含中间层)。首删时 `~/.dsh/session-trash/` 还不存在,而 `rename` 不建父目录。 */
4
+ ensureDir(p: string): Promise<void>;
5
+ rename(from: string, to: string): Promise<void>;
6
+ removeDir(p: string): Promise<void>;
7
+ sizeOfDir(p: string): Promise<number>;
8
+ /** 目录下的条目名(**不递归**)。回收站详情要用它在那条目录里挑 `*.jsonl.zstd`。 */
9
+ listFiles(p: string): Promise<string[]>;
10
+ /** 目录下的**子目录**名(不递归)。回收站对账用它找出"清单里还没有行"的条目目录——
11
+ * 与 `listFiles` 分开是为了让"拒收文件"发生在挑选阶段(回收站根里落进来的杂散文件当外人)。 */
12
+ listDirs(p: string): Promise<string[]>;
13
+ /** 整个文件读进内存。实测本机回收站里最大单文件 4.66 MB;**内存口径由逐帧解压把住**(见 `src/log/session-log.ts`),不在这里。 */
14
+ readFile(p: string): Promise<Buffer>;
15
+ }
16
+ export declare function createFsPort(): FsPort;
@@ -0,0 +1,97 @@
1
+ /** 摘内存的三种落地(见 {@link HostPort.evictLive}):`not-live` / `evicted` 都意味着**宿主列表已经不含它**;
2
+ * `unknown` 意味着读不到或摘不掉,**不能**据此通告客户端(发了就是假话,重连后那一行会自己长回来)。 */
3
+ export type EvictionOutcome = 'evicted' | 'not-live' | 'unknown';
4
+ export interface HostPort {
5
+ workspaceOf(sessionId: string): Promise<string | undefined>;
6
+ /** 账本里还有这个工作区吗。**只读的判别**(`registry.get(id) !== undefined`),不抛。
7
+ *
8
+ * **为什么恢复需要它**:宿主的「删除工作区」只删**注册**、保留目录与每一份会话日志
9
+ * (asar `WorkspaceRegistry.delete` 的 JSDoc 原文 "Delete one workspace registration while
10
+ * retaining its directory and every session log"),那些会话之后显示在「未分组」下。回收站里
11
+ * **删在工作区之前**的条目记着那个已被删掉的 `workspaceId`,恢复时若照旧 `attach` 就会
12
+ * `workspace not found` 整条失败(会话留在回收站里,用户看起来就是"恢复报错")。所以恢复
13
+ * 先问这一句:不在 ⇒ 不挂账本,照常回盘、归入「未分组」(与宿主自己处理这类会话一致)。 */
14
+ workspaceExists(workspaceId: string): Promise<boolean>;
15
+ /** 会话落盘的**项目目录名**(`<sessions>/<projectKey(cwd)>`);账本里没有这个会话 ⇒ `undefined`。
16
+ * 与 `workspaceOf` 同一口径:**未命中是正常的 no-op,不是故障**(存储真故障照抛)。 */
17
+ projectDirOf(sessionId: string): Promise<string | undefined>;
18
+ /** 删除时**尽力**取的会话标题;取不到返回空串,绝不因此让删除失败。
19
+ * 标题只存在于宿主自己的账本/缓存里(`FsPort` 不直读文件)⇒ 取数面归宿主端口,接官方投影缓存 API。 */
20
+ titleOf(sessionId: string): Promise<string>;
21
+ isLive(sessionId: string): Promise<boolean>;
22
+ isArchived(sessionId: string): Promise<boolean>;
23
+ /** 尽力把会话从宿主**内存里的 live store** 摘掉;三态见 {@link EvictionOutcome}。
24
+ * **这是本仓走宿主内部入口的两处之一**(另一处是 {@link HostPort.announceRemoved} 发的转发事件,
25
+ * 理由与代价见实现处的注释):删除的两半里,"磁盘上的持久化"我们搬得走,
26
+ * "内存里 attach 着的活体"只有这一条路能请掉。 */
27
+ evictLive(sessionId: string): Promise<EvictionOutcome>;
28
+ /** **如实通告**客户端"这个会话没了":发宿主自己声明并转发的 `api-session/removed`,客户端当场删行。
29
+ * 冷会话没有活体可摘,但同样需要这一条——否则那一行要等到重连(`handleConnected` → 重列)才消失。 */
30
+ announceRemoved(sessionId: string): Promise<void>;
31
+ /** **如实通告**客户端"这个会话回来了":摘要取自宿主自己的会话列表,发 `api-session/added`。
32
+ * 取不到摘要就不发(恢复本身已经完成)。 */
33
+ announceRestored(sessionId: string): Promise<void>;
34
+ /** 宿主会话表的一次预取:`[{ sessionId, summary }]`,`summary` 是宿主**原样**那一条。
35
+ * 给 {@link announceRestoredBatch} 当 `known` 用——整批只拉一次表。服务没挂回空表,不抛。 */
36
+ restoredSummariesOf(): Promise<readonly {
37
+ readonly sessionId: string;
38
+ readonly summary: unknown;
39
+ }[]>;
40
+ /** 一组会话的通告(2026-10-17 批量重构):`known` 是调用方**整批预取**的摘要表——给了就一次都不
41
+ * 再拉表(每条一次 `controller.list()` 在 100 条的批量里就是 100 遍全表扫,那是那次"恢复很慢"的
42
+ * 头号来源);没给则自己拉一次。缺席 id 不发,不影响已完成的恢复。 */
43
+ announceRestoredBatch(ids: readonly string[], known?: readonly {
44
+ readonly sessionId: string;
45
+ readonly summary: unknown;
46
+ }[]): Promise<void>;
47
+ unarchive(sessionId: string): Promise<void>;
48
+ detach(sessionId: string, workspaceId: string): Promise<void>;
49
+ attach(sessionId: string, workspaceId: string): Promise<void>;
50
+ /** 归档是 registry **全局**的,与工作区账目无关 ⇒ `workspaceId` 只是调用点对称用的可选入参
51
+ * (未分组的会话没有工作区可传)。 */
52
+ archive(sessionId: string, workspaceId?: string): Promise<void>;
53
+ /** 数一组父会话在盘上的**后代**条数(孩子与孙代都算——回收站清单上那个只读计数,宿主客户端把
54
+ * `origin:"subagent"` 的行藏掉了 ⇒ 不说就没人知道删了父还剩多少)。键按**传进来的那个写法**回;
55
+ * 没有后代的不表现在结果里(缺省即 0);`sessionQuery` 没挂回 `{}`,不抛。 */
56
+ subagentCounts(parentIds: readonly string[]): Promise<Record<string, number>>;
57
+ /** 列出这个父会话**在盘上的全部后代**(删除时一起带走)。来历与缺席行为见实现处:服务没挂回 `[]`。 */
58
+ subagentMembers(sessionId: string): Promise<readonly {
59
+ readonly sessionId: string;
60
+ readonly projectDir: string;
61
+ }[]>;
62
+ }
63
+ /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
64
+ * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
65
+ * `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
66
+ export declare const NO_CWD_PROJECT_DIR = "_no-cwd";
67
+ /**
68
+ * 宿主的**项目目录名**算法(`projectKey`)逐字镜像。
69
+ *
70
+ * 为什么是镜像而不是 import:它在 `@deepseek-ai/dsh-session-persistence-jsonl` 里面,是**宿主包**,
71
+ * 跨插件值 import 是生态红线,它也不在本插件依赖里;宿主没有把这个名字暴露成任何服务
72
+ * (`stat()`/`list()` 的返回只有 `{header, revision, sizeBytes}`,`locate()` 的 `{kind:'jsonl', path}` 只挂在错误上)。
73
+ *
74
+ * 原文(asar `dsh/node_modules/@deepseek-ai/dsh-session-persistence-jsonl/lib/index.js`):
75
+ * `function projectKey(cwd) { … if (ch === "/" || ch === "\\" || ch === ":") { if (!separatorRun) readable += "-"; separatorRun = true; }
76
+ * else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) { readable += ch; separatorRun = false; }
77
+ * else { readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false; }
78
+ * return \`--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--\`; }`
79
+ * 调用链:`locate(meta)` → `logPath(root, meta.cwd, meta.id, …)` → `projectDir(root, cwd)` → `projectKey(cwd)`。
80
+ * 本机实测对得上:`D:/develop/GitHub/dsh-session-delete` → `--D-develop-GitHub-dsh-session-delete--`
81
+ * (`~/.dsh/sessions` 下真有这个目录名;`D:/document/deepseek-harness/default-workspace` 同理)。
82
+ *
83
+ * **两个调用方共用这一份**:`projectDirOf`(删除时定位会话目录)与回收站对账的重建路径
84
+ * (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
85
+ */
86
+ export declare function projectKey(cwd: string): string;
87
+ /** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
88
+ * 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
89
+ export declare function isWorkspaceMissingError(error: unknown): boolean;
90
+ /**
91
+ * 宿主服务层的**真实绑定**:各方法把动词层的问题翻成宿主官方服务的调用。
92
+ *
93
+ * 每个方法的依据(服务 / 方法 / 入参)与原文切片见各自的实现处注释。
94
+ * 出错一律**原样抛**(不在这里裹动词码):动词层把原始错误挂在 `cause` 上,
95
+ * `toRemoteFailure` 再按 `name` 把它映射回 `sessiondelete/<码>`(设计稿 §6 的两行映射)。
96
+ */
97
+ export declare function createHostPort(ctx: unknown): HostPort;
@@ -0,0 +1,19 @@
1
+ import { type RemoteFailure } from '../shared/wire.js';
2
+ /**
3
+ * 把任意抛出物折成失败信封里的**那三件**(设计稿 §6 唯一那张映射表)。
4
+ *
5
+ * - 已经是远程失败(`isDSHRemoteError` 认得的结构标记)⇒ 原样带出它自己的 `code` / `message`,
6
+ * 挂着的 `details` 一起过桥,不再包一层。
7
+ * - **带官方形状 code 的抛出物**(`workspace/session-active`、`session/not-found`…)⇒ **原样过桥**。
8
+ * 把它们折成 `sessiondelete/io` 就是把宿主的判断抹成我们的,客户端拿到的 code 从此不能说真话。
9
+ * - 宿主类型化错误(自身或 `cause` 链上,见 {@link hostErrorOf})⇒ 映射成 `live` / `not-found`,
10
+ * 并把它自己身上的结构化字段带成 `details`。这一条排在动词码之前:动词会把宿主错误包成笼统的 `io`
11
+ * 并把原错误挂在 cause 上(`verbError('io', …, cause)`),不看进 cause 链,§6 那两行映射永远到不了客户端。
12
+ * - 动词的 `sessiondelete/<五个码>` ⇒ 原样过桥(`details` 同样带着)。
13
+ * - 其余(别域的怪 code、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
14
+ *
15
+ * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封
16
+ * (以及 `verbs/apply.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
17
+ * `RemoteError` 类(实例也上不了线:信封只走这三件)。
18
+ */
19
+ export declare function toRemoteFailure(error: unknown): RemoteFailure;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 跨半 wire 契约:`/dsh-session-delete-api` 的**前缀、五个端点段、请求字段名**——
3
+ * Node 半(`src/api/dispatch.ts` 注册并分发)与浏览器半(`src/client/remote.ts` 同源 fetch)
4
+ * 之间唯一的那份真相。两侧都 import 它,不各写一份字面量。
5
+ *
6
+ * 两条构建期硬约束(不是偏好):
7
+ * ① tsdown 的浏览器半纯度门拦 Node 内建与平台模块表外的 `@deepseek-ai/*` 值 import——
8
+ * 本文件两者都不涉及,且**零运行时依赖**(client 半把它整个 inline 进 bundle);
9
+ * ② 跨插件一律 `import type`——本文件干脆没有 import。
10
+ *
11
+ * 信封(`RemoteResult`)与失败形状(`RemoteFailure`)也在这里——**因为它们本来就是两半的共同契约**:
12
+ * Node 半按它产出(`src/api/dispatch.ts` 的失败支就是 `RemoteFailure` 那三件),浏览器半按它判别
13
+ * (`src/client/remote.ts` 的 `unwrapRemoteResult` / `failureOf`)。形状取自宿主网关客户端面的原文
14
+ * 「每次 unary 调用 resolve 成 RemoteResult、不为载体问题 reject」,但**它是本仓自己的 wire 契约**,
15
+ * 不是对宿主模块的 import。
16
+ */
17
+ /**
18
+ * 插件专属前缀。
19
+ *
20
+ * 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
21
+ * 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
22
+ * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
23
+ */
24
+ export declare const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
25
+ /** 五个段名(相对前缀)。2026-10-17 批量重构:写动词从「一条一段」收成**一段 `/apply`**——
26
+ * 一次 HTTP 带一组 id、逐条回结果;单条就是长度为 1 的批量,仓里不再留第二条执行路径。
27
+ * - `GET /list`:**对账并读**清单(`session-trash/` 里的目录是准)。
28
+ * - `GET /ping`:最便宜的在/不在(不碰清册、不碰盘、不进链)——客户端探测用它,不用一次全表读。
29
+ * - `POST /detail`:单条详情(用 POST 表达一个读:见设计稿 2026-10-15 §3)。
30
+ * - `POST /apply`:批量执行一个动作(`delete` / `restore` / `purge` / `count`),逐条回 `ApplyOutcome`。
31
+ * - `POST /empty`:清空回收站(聚合回执,**不**按条回)。 */
32
+ export declare const ROUTES: {
33
+ readonly list: "/list";
34
+ /** 通道的"在不在"专用:没有入参、没有副作用、没有磁盘读。 */
35
+ readonly ping: "/ping";
36
+ /** 单条详情(读回收站目录里那条会话自己的日志)。 */
37
+ readonly detail: "/detail";
38
+ /** 一组 id 上的同一个动作;删除确认框那句「含 N 个子会话」也走这里(`action: 'count'`,纯读语义)。 */
39
+ readonly apply: "/apply";
40
+ /** 清空回收站。 */
41
+ readonly empty: "/empty";
42
+ };
43
+ /** `/apply` 的四个动作。id 的含义随动作走:`delete`/`count` 是**宿主会话 id**(还没进回收站),
44
+ * `restore`/`purge` 是**回收站条目 id**。 */
45
+ export type ApplyAction = 'delete' | 'restore' | 'purge' | 'count';
46
+ /** 每个动作的成功值:`count` 是一个数(拿不到就是 0,界面上那半句整句不出现);
47
+ * `delete`/`restore` 是回收站那一行(`null` = 这一条已被**同批前一条**带走,幂等 no-op);
48
+ * `purge` 是这一组的聚合回执(同批已带走 ⇒ `{removed:0,freedBytes:0}`)。 */
49
+ export type ApplyValueByAction = {
50
+ delete: TrashEntryWire | null;
51
+ restore: TrashEntryWire | null;
52
+ purge: PurgeStatsWire;
53
+ count: number;
54
+ };
55
+ /** 一条动作的落地:原始输入 id 一律回带,成败各按信封(成功 `value` / 失败 `error`)。
56
+ * 按 `action` + `ok` 双重可判别——浏览器半的便捷包装靠它把 `value` 收成各自动作的类型。 */
57
+ export type ApplyOutcome = {
58
+ id: string;
59
+ } & {
60
+ [A in ApplyAction]: {
61
+ action: A;
62
+ ok: true;
63
+ value: ApplyValueByAction[A];
64
+ } | {
65
+ action: A;
66
+ ok: false;
67
+ error: RemoteFailure;
68
+ };
69
+ }[ApplyAction];
70
+ /** `TrashEntry` 的线上形状(浏览器半自己有一份窄镜像 `TrashEntryLike`)。 */
71
+ export interface TrashEntryWire {
72
+ id: string;
73
+ sessionId: string;
74
+ projectDir: string;
75
+ workspaceId: string;
76
+ wasArchived: boolean;
77
+ deletedAt: number;
78
+ title: string;
79
+ sizeBytes: number;
80
+ parentEntryId?: string;
81
+ }
82
+ /** 清空回收站的回执(与旧 `purge` 同形)。 */
83
+ export interface PurgeStatsWire {
84
+ removed: number;
85
+ freedBytes: number;
86
+ }
87
+ /** 请求体字段名:浏览器半发、Node 半读,同名同义(`empty` 没有入参)。 */
88
+ export declare const PARAMS: {
89
+ readonly entryId: "entryId";
90
+ readonly action: "action";
91
+ readonly ids: "ids";
92
+ };
93
+ /**
94
+ * 失败信封的**线上形状**(设计稿 §6):`code` 是判别依据、`message` 是上屏原文、
95
+ * `details` 是**可选的结构化附带物**——对齐官方 `rpcErrorSchema` 那三件
96
+ * (`dsh-client-connection` 原文:`z.object({ code, message, details: z.record(z.string(), z.unknown()) })`)。
97
+ * 来历两类:宿主类型化错误自己身上的字段(`WorkspaceActiveSessionError` 的 `sessionId` / `activity`…),
98
+ * 以及动词在抛出处就地给的(`verbError(code, msg, cause, details)`)。没有附带物时**整件缺席**,
99
+ * 不写空表——"带过但为空"和"没有可带的"是两件事。
100
+ */
101
+ export interface RemoteFailure {
102
+ code: string;
103
+ message: string;
104
+ details?: Record<string, unknown>;
105
+ }
106
+ /** 两半之间的一次落地(**信封**):要么值,要么失败。 */
107
+ export type RemoteResult<T> = {
108
+ ok: true;
109
+ value: T;
110
+ } | {
111
+ ok: false;
112
+ error: RemoteFailure;
113
+ };
114
+ /**
115
+ * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
116
+ * 位名与判法取自宿主网关客户端面的原文:
117
+ * `if (typeof value === "object" && value !== null && value.isDSHRemoteError === true && typeof value.code === "string") return value`。
118
+ *
119
+ * 线上递过来的是**一段 JSON**(标记位不跟着过线)⇒ 落到哪一端,由那端的构造处就地补上标记:
120
+ * 浏览器半是 `src/client/remote.ts` 的 `throwRemoteFailure`。
121
+ */
122
+ export declare function isDSHRemoteError(value: unknown): value is RemoteFailure;
123
+ /**
124
+ * 宿主 `projectKey` 的 **`~XXXX` 转义还原**。
125
+ *
126
+ * 编码那一半在 Node 侧(`src/ports/host-port.ts` 的 `projectKey`,逐字镜像宿主实现);这里放与它配对的
127
+ * 解码,客户端渲染回收站组头用它——**字母表只有这一份**,两边不会各自漂移(配对关系由
128
+ * `tests/assembly.test.ts` 的往返用例钉住)。
129
+ *
130
+ * **只还原可还原的那部分**:不在 `[A-Za-z0-9._-]` 里、又不是 `~` 的字符编成 `~` + 4 位大写十六进制码元,
131
+ * 字面 `~` 自己编成 `~007E` ⇒ `~XXXX` 不含歧义。而**分隔符(`/` `\` `:`)被折成一个 `-`,那一段是有损的**,
132
+ * 这里还原不回来——调用方按"尽力显示"用它,别拿它反推真实路径。
133
+ */
134
+ export declare function unescapeProjectKey(encoded: string): string;
@@ -0,0 +1,40 @@
1
+ import type { ManifestPort } from './manifest.js';
2
+ import type { FsPort } from '../ports/fs-port.js';
3
+ /** 每条文字的上限(字符)。超出即截断并置 `truncated`。 */
4
+ export declare const PER_MESSAGE_CHAR_CAP = 3000;
5
+ /** 一次回信的正文总量上限(字符)。 */
6
+ export declare const TOTAL_CHAR_CAP = 500000;
7
+ export interface TrashDetailMessage {
8
+ role: 'user' | 'assistant';
9
+ time: number;
10
+ text: string;
11
+ truncated: boolean;
12
+ }
13
+ export interface TrashDetail {
14
+ entryId: string;
15
+ /** 日志 header 里的原串(**无损**,比从 `projectKey` 反推那一路强,设计稿 §7 组头那条是另一回事)。 */
16
+ cwd: string;
17
+ createdAt: number;
18
+ lastActivity: number;
19
+ /** 日志内 `session/title` 那条记录;清册标题为空串时客户端拿它兜底。 */
20
+ logTitle: string;
21
+ models: string[];
22
+ turnCount: number;
23
+ userMessageCount: number;
24
+ assistantMessageCount: number;
25
+ toolCallCount: number;
26
+ injectedCount: number;
27
+ messages: TrashDetailMessage[];
28
+ omittedByPayloadCap: number;
29
+ }
30
+ export type DetailDeps = {
31
+ fs: FsPort;
32
+ manifest: ManifestPort;
33
+ home: string;
34
+ };
35
+ /** 两个载荷闸(可调是为把策略单独验穿;生产走的就是这两个默认值,用例里钉着)。 */
36
+ export interface DetailLimits {
37
+ perMessageChars: number;
38
+ totalChars: number;
39
+ }
40
+ export declare function readTrashDetail(deps: DetailDeps, limits?: DetailLimits): (entryId: string) => Promise<TrashDetail>;
@@ -0,0 +1,35 @@
1
+ /** 回收站的一条记录。`id` 由 `makeEntryId` 生成(`<删除时刻>-<原目录名>`)——**它不是随手起的键**:
2
+ * 回收站目录名就是它,恢复也按它反推出原目录名放回(设计稿 §3),所以 id 的形状有契约意义。
3
+ * 其余字段在删除那一刻就地取。 */
4
+ export interface TrashEntry {
5
+ id: string;
6
+ sessionId: string;
7
+ projectDir: string;
8
+ /** 删除时它所属的工作区;**空串 = 删除时就没有账目**(「未分组」的会话,宿主的"删除工作区"会造出这种),
9
+ * 恢复时据此跳过 `attach`。 */
10
+ workspaceId: string;
11
+ /** 删除**之前**的归档态。删除会把"原本未归档"的会话归档(藏起来,免得它掉进「未分组」),
12
+ * 恢复时按这个字段还原可见性:true ⇒ 仍处归档态,false ⇒ 取消归档放回树里。 */
13
+ wasArchived: boolean;
14
+ deletedAt: number;
15
+ /** 删除时**尽力**取的会话标题(来源:`storages/session_projcache/sessions/<会话id>.json`
16
+ * 里的 `rows.title.val`);取不到存空串,绝不因此让删除失败。 */
17
+ title: string;
18
+ /** 删除时用 `fs.sizeOfDir` 量到的会话目录字节数。 */
19
+ sizeBytes: number;
20
+ /** **组语义**:这一条是跟着父一起进回收站的子会话 ⇒ 记父那一行的 `id`。
21
+ * 缺席 = 这一行是父(或它自己就是被单独删掉的)。清单据此把孩子折进父行、不单独列。
22
+ * 老代留底 / 清册被清后由目录重建出来的行**没有这个字段**(文件侧没有它的副本,与
23
+ * `workspaceId`/`wasArchived`/`title` 同一类降级),那一趟它们会各占一行——那是"分组信息随清册一起没了"
24
+ * 的如实结果,不假装知道(设计稿 2026-10-15 §10)。 */
25
+ parentEntryId?: string;
26
+ }
27
+ export declare function makeEntryId(deletedAt: number, sessionDirName: string): string;
28
+ /** `makeEntryId` 的逆。时间戳按 UTC 拆字段再 `Date.UTC` 合回——不交给 `Date.parse`
29
+ * 去猜(`20260930T130500Z` 不是任何被承认的日期格式,猜出来的是 NaN)。 */
30
+ export declare function parseEntryId(id: string): {
31
+ deletedAt: number;
32
+ sessionDirName: string;
33
+ };
34
+ export declare function sessionDirOf(projectDir: string, sessionDirName: string, home: string): string;
35
+ export declare function trashDirOf(entryId: string, home: string): string;
@@ -0,0 +1,18 @@
1
+ import type { TrashEntry } from './entry.js';
2
+ /** 回收站清单的**唯一**读法(动词只依赖这个端口,不认 storage domain 的形状)。 */
3
+ export interface ManifestPort {
4
+ list(): Promise<TrashEntry[]>;
5
+ add(e: TrashEntry): Promise<void>;
6
+ remove(id: string): Promise<void>;
7
+ }
8
+ export declare function createMemoryManifest(seed?: TrashEntry[]): ManifestPort;
9
+ /** 宿主 storage domain 的最小面。这是**接缝**,不是对宿主 API 的断言:
10
+ * 探针只枚举到 `ctx.storageDomain` 有 `open` / `get` / `domains` / `reserved` 等成员,
11
+ * **没有**枚举 `open()` 返回的 domain 句柄面 ⇒ 真实绑定由 `createStorageManifest` 写一层薄适配
12
+ * (官方读面若只有迭代器/快照,就把迭代器折成 `readAll()`,不在动词层引入第二种读法)。 */
13
+ export interface StorageDomainLike {
14
+ readAll(): Promise<TrashEntry[]>;
15
+ put(record: TrashEntry): Promise<void>;
16
+ delete(key: string): Promise<void>;
17
+ }
18
+ export declare function createStorageManifest(domain: StorageDomainLike): ManifestPort;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 回收站清单的**对账**:以 `session-trash/` 里的条目目录为准,把清单修回一致(设计稿 2026-10-15 §9)。
3
+ *
4
+ * 手工动回收站**只需要动那一个目录**——多出来的目录在这里被重建出行、目录没了的行在这里被摘掉;
5
+ * `~/.dsh/storages/` 那份清单从此是**内部索引**,不用手碰。
6
+ *
7
+ * 五条口径(都不是口味):
8
+ * ① **每次读清单都对账**(`GET /list` 的正文就是"对账 + 读"):挂载、刷新、别处动过后的自动重读、
9
+ * 客户端启动探测全走这条 ⇒ "改完目录不用再做任何事,下一次读就对了"。代价 = 每次读多一次目录枚举
10
+ * (本机 124 条,毫秒级);**零漂移零写入**(快路直接返回原清单)。
11
+ * ② **回收站根不在 ⇒ 整套跳过、一个字节都不动**:那不是"回收站空了",是"这块地不在原处"
12
+ * (人为搬走、盘没挂上)——照常对账会把全部行的元数据一次判没。
13
+ * ③ **重建行只写"文件里真有的事实"**:`id` = 目录名、`sessionId`/`cwd` 读日志 header、
14
+ * `projectDir` 由 `cwd` 经宿主同名算法 `projectKey` 反推、`deletedAt` 从目录名的时间戳解、
15
+ * `sizeBytes` 现场量。**文件侧没有副本的三件**按缺省落:`workspaceId: ''`(恢复时跳过挂账本,
16
+ * 落在「未分组」)、`wasArchived: false`(恢复按"可见"还原)、`title: ''`(详情有日志标题兜底)
17
+ * ——这是重建的固有代价,不是可以"再读读看"的东西。
18
+ * ④ **认不出 / 读不出的目录:不动、不列**(名字不像条目、目录里没有日志、header 读不出、尺寸量不出,
19
+ * 各跳过各的)。它是"外人放进来的东西",不是用户动作失败:跳过即静默,绝不为它删目录或抛掉整次读数。
20
+ * ⑤ **幂等**:再跑一遍零写入;崩在任何一步,下一次读清单接着把它修完。
21
+ *
22
+ * 与三个写动词的**串行化不在这里**(那是传输层的事,见 `src/api/dispatch.ts` 的到达序链):
23
+ * 对账与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩——删除刚把目录搬进
24
+ * 回收站、行还没写上时,对账抢先把这行按目录"重建"出来,随后又被真实行覆盖成降级元数据。
25
+ */
26
+ import { type TrashEntry } from './entry.js';
27
+ import type { ManifestPort } from './manifest.js';
28
+ import type { FsPort } from '../ports/fs-port.js';
29
+ export interface ReconcileDeps {
30
+ fs: FsPort;
31
+ manifest: ManifestPort;
32
+ home: string;
33
+ }
34
+ /** 对账并回清单——**返回的就是修好之后的那份**(与 `manifest.list()` 同序:最近删除在前)。 */
35
+ export declare function reconcileTrash(deps: ReconcileDeps): Promise<TrashEntry[]>;
@@ -0,0 +1,3 @@
1
+ import type { ApplyAction, ApplyOutcome } from '../shared/wire.js';
2
+ import type { DeleteDeps } from './delete-session.js';
3
+ export declare function applyActions(deps: DeleteDeps, action: ApplyAction, ids: readonly string[]): Promise<ApplyOutcome[]>;
@@ -0,0 +1,30 @@
1
+ import { type TrashEntry } from '../trash/entry.js';
2
+ import type { FsPort } from '../ports/fs-port.js';
3
+ import type { HostPort } from '../ports/host-port.js';
4
+ import type { ManifestPort } from '../trash/manifest.js';
5
+ export interface DeleteDeps {
6
+ fs: FsPort;
7
+ host: HostPort;
8
+ manifest: ManifestPort;
9
+ home: string;
10
+ now(): number;
11
+ }
12
+ /**
13
+ * 删除 = 移入回收站,**按组走**:用户点掉一条父会话时,宿主的客户端把 `origin:"subagent"` 的孩子
14
+ * 整排藏了 ⇒ 那些目录既看不见也继续占着字节(本机实测 41 条父留了 320 个目录 / 74.6 MB)。
15
+ * 所以这里把父在盘上的孩子一起搬进来,各自成目录、各自一行,清册用 `parentEntryId` 记它是谁的孩子。
16
+ *
17
+ * 三段,顺序承重:
18
+ * ① **先点组、先验活体**(一个字节都不写)。任一成员在跑就整组拒绝——不替用户终止正在跑的活,
19
+ * 也绝不允许"父已经搬进回收站、孩子因为在跑而搬不动"这种半套。
20
+ * ② **逐个搬**:每个成员内部沿用原来那套顺序与就地回滚(归档 → 摘账本 → 搬目录 → 写清册),
21
+ * 任何一步失败就把这个成员自己退回原地。
22
+ * ③ **组级回滚**:某一个成员搬不动时,把**已经搬进去的**那些按相反顺序退回原处(清册行跟着摘、
23
+ * 账本跟着挂、可见性跟着还原),失败照抛——不留"半组在回收站、半组在原地"的世界。
24
+ */
25
+ export declare function deleteSession(deps: DeleteDeps): (sessionId: string,
26
+ /** 批量层(`verbs/apply.ts`)在写之前收一份"这次真正要搬的成员"(父 + 盘上存在的孩子),
27
+ * 成功后用它记下"已被同批前一条带走"的 id。单条调用不传。 */
28
+ options?: {
29
+ readonly onMembers?: (sessionIds: readonly string[]) => void;
30
+ }) => Promise<TrashEntry>;
@@ -0,0 +1,20 @@
1
+ /** 五个码(设计稿 §6 的表)。**唯一定义处**:`remote/map-error.ts` 从这份数组反推它的 `Code`,
2
+ * 免得"加了一个码只在一边加"让两张表各自漂移(漂移的后果是那个码静默降级成 `io`)。 */
3
+ export declare const VERB_CODES: readonly ["live", "not-found", "trash-empty", "project-missing", "io"];
4
+ export type VerbCode = (typeof VERB_CODES)[number];
5
+ /** 路由层的自检(不是五个动词码之一):**请求本身不对**——方法、路径、body 形状。
6
+ * 它上的是 HTTP 状态那条口(404/405),信封里也带着它,好让只看信封的调用方拿到同一句原文。 */
7
+ export declare const ROUTE_CODES: readonly ["bad-method", "not-found-route", "bad-request"];
8
+ export type RouteCode = (typeof ROUTE_CODES)[number];
9
+ /** 一次失败的**结构**:码、上屏原文、可选 cause(`map-error` 沿 cause 链认宿主类型化错误)、
10
+ * 可选的结构化附带物(对齐官方 `rpcErrorSchema` 的第三件 `details`)。 */
11
+ export interface VerbError extends Error {
12
+ code: string;
13
+ details?: Record<string, unknown>;
14
+ cause?: unknown;
15
+ }
16
+ export declare function verbError(code: VerbCode, message: string, cause?: unknown, details?: Record<string, unknown>): VerbError;
17
+ /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
18
+ export declare function routeError(code: RouteCode, httpStatus: number, message: string): VerbError & {
19
+ httpStatus: number;
20
+ };
@@ -0,0 +1,6 @@
1
+ import { type TrashEntry } from '../trash/entry.js';
2
+ import type { DeleteDeps } from './delete-session.js';
3
+ export declare function purgeTrash(deps: DeleteDeps): (entryId?: string, snapshot?: TrashEntry[]) => Promise<{
4
+ removed: number;
5
+ freedBytes: number;
6
+ }>;