@xrn1997/dsh-session-delete 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `/dsh-session-delete-api` 前缀路由的**内部分发**(薄线的 Node 半)。
2
+ * `/dsh-session-delete-api` 前缀路由的**内部分发**(薄线的 Node 半)——**只管传输**。
3
3
  *
4
4
  * 为什么是这一条路(裁决,不是偏好):浏览器侧 `remote.<namespace>` 只能由**生成的 Typert 远程
5
5
  * 描述符贡献**经 `ctx.remote.$mount(...)` 挂上,而盒内那份装配清单是**写死的 25 条官方包**、
@@ -7,49 +7,29 @@
7
7
  * 只能自挂自(要生成物 + `zod` + 改包布局),代价与收益完全不成比例。改用宿主**自己的 web server**
8
8
  * 开一条插件专属前缀路由:真相/账本/判据全在宿主那边,这一层只搬运。
9
9
  *
10
- * 四条口径:
11
- * ① **不依赖 Cordis**:`createApiHandler(deps)` 是纯构造器,返回一个 `(req, res) => Promise<void>`
12
- * ——所以它能用真 `node:http` server 直接单测(`tests/api.test.ts`),与宿主怎么挂它无关。
13
- * ② **信封就是浏览器半消费的那个形状**:`{ok:true,value}` / `{ok:false,error:{code,message,details?}}`。
14
- * `error.code` 走 `src/remote/map-error.ts` 的**同一张表**(设计稿 §6 的五个码),原文进
15
- * `error.message`,结构化附带物进可选的 `error.details`——浏览器半的 `unwrapRemoteResult` /
16
- * `failureOf` 因此一行都不用改。
17
- * ③ **HTTP 状态不是第二套语义**:**操作失败**时状态一律 200(含 `ok:false`)——我们与浏览器半之间的
18
- * 契约是信封本身,判断成败只看 `ok`(同宿主网关"每次 unary 调用 resolve 成 RemoteResult、
19
- * 不为载体问题 reject"的那条规矩)。**请求本身不对**才用 4xx(400 请求体 / 405 方法 / 404
20
- * 未知路由),同源栅栏拒的是**请求**而不是操作,回 403;浏览器半先解信封再看状态。
21
- * ④ **同源栅栏**:本机回环上一条能删会话的写口不能被任意网页用 simple request CSRF 打——没有栅栏时
22
- * `content-type: text/plain` 的跨源 POST 不触发预检、直接生效。放行的形状见 {@link isTrustedRequest}:
23
- * 同源(浏览器直接打开宿主那行 URL)、桌面壳自己的页面(`dsh-app://app`,真机那条路,见
24
- * {@link isDesktopShellUrl})、或两者皆缺席(curl / 本机工具)。本机既有插件
25
- * (`dsh-novel` 的 `api/wire.ts`)只认前两条里的"同源",本文件多的是桌面壳那一条——**它必须有**:
26
- * 桌面版的请求被壳摘掉了 `Origin`,漏掉它等于真机上四个入口一个都不出现(而直连 http 的过门
27
- * 读数看不出来)。
28
- * ⑤ **`list` 是"对账 + 读",且它与写动作按到达序串行**({@link createApiHandler} 里那条进程内链):
29
- * 对账(`reconcileTrash`)与删除/恢复/彻底删除都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩
30
- * ——删除刚把目录搬进回收站、行还没写上时,对账抢先把这行按目录"重建"出来,再被真实行覆盖成降级
31
- * 元数据。串行的代价只是排队(单进程单用户,毫秒级);纯读(`detail`、`ping`、`apply/count`)
32
- * 不占这条链。
33
- * ⑥ **一段 `POST /apply` 管所有写动作**(2026-10-17 批量重构):旧形状是「一条一段、一次一发」,
34
- * 批量只能由调用方循环拼出来——100 条 = 100 次往返 ×(一次全量清册读 + 每条一次宿主会话表)。
35
- * 现在一组 id 一次进、逐条 `ApplyOutcome` 出;单条就是 N=1,仓里不留第二条执行路径。
36
- * 清空是聚合回执(不按条回),所以单独一段 `POST /empty`。
10
+ * 本文件只剩三件事:**栅栏 → 查表 → 信封**。
11
+ * - "这个段名该用什么方法、纯不纯、要不要预读 body、怎么处理"全在 `api/route-table.ts` 那一张表里
12
+ * (ADR-0004);这里一个字都不再重复(此前方法判断与纯度判断分居两个函数、靠注释保持一致)。
13
+ * - 一行里放什么(清册行 → 线上行)归 `api/trash-rows.ts`。
14
+ * - 前面四条口径(不依赖 Cordis / 信封与浏览器半同形 / 状态语义 / 同源栅栏)与到达序链(口径 ⑤)
15
+ * 仍住这里,因为它们是**传输**的事。
16
+ *
17
+ * 信封(口径 ②):`{ok:true,value}` / `{ok:false,error:{code,message,details?}}`。
18
+ * `error.code` 走 `src/remote/map-error.ts` 的**同一张表**(设计稿 §6 的五个码),原文进
19
+ * `error.message`,结构化附带物进可选的 `error.details`。
20
+ *
21
+ * HTTP 状态不是第二套语义(口径 ③):**操作失败**时状态一律 200(含 `ok:false`)——我们与浏览器半之间的
22
+ * 契约是信封本身,判断成败只看 `ok`。**请求本身不对**才用 4xx(400 请求体 / 405 方法 / 404 未知路由),
23
+ * 同源栅栏拒的是**请求**而不是操作,回 403;浏览器半先解信封再看状态。
24
+ *
25
+ * 同源栅栏(口径 ④):本机回环上一条能删会话的写口不能被任意网页用 simple request CSRF 打——没有栅栏时
26
+ * `content-type: text/plain` 的跨源 POST 不触发预检、直接生效。放行的形状见 {@link isTrustedRequest}。
27
+ *
28
+ * 到达序链(口径 ⑤):`list`(含对账)与写动作都是"读清单 + 动文件 + 写清单"的多步,交错跑会互相踩
29
+ * ——删除刚把目录搬进回收站、行还没写上时,对账抢先把这行按目录"重建"出来,再被真实行覆盖成降级
30
+ * 元数据。串行的代价只是排队(单进程单用户,毫秒级);纯读不占这条链(哪几个段名纯、哪个动作纯,
31
+ * 由路由表说,不在这里列)。
37
32
  */
38
33
  import type { IncomingMessage, ServerResponse } from 'node:http';
39
- import type { TrashEntry } from '../trash/entry.js';
40
34
  import type { DeleteDeps } from '../verbs/delete-session.js';
41
- /** 清单交回浏览器的那一行:清册那一行 + 组视图的只读数。 */
42
- export interface TrashRow extends TrashEntry {
43
- /** 跟着这条父会话一起进回收站的孩子数(来源:清册里 `parentEntryId` 指向它的行)。 */
44
- readonly subagentCount?: number;
45
- /** 那些孩子的字节合计(页脚的合计要算得上它们,不能只加父自己那一格)。 */
46
- readonly subagentBytes?: number;
47
- /** **盘上还留着、没被带走**的孩子数(来源:宿主 `sessionQuery`)。级联落地前删掉的那些存量才非零;
48
- * 与 `subagentCount` 是两件事,界面上必须各说各的(不许用一个冒充另一个)。 */
49
- readonly strandedSubagents?: number;
50
- /** 删除时记下的工作区**已经不在账本里**(宿主的「删除工作区」只删注册、保留日志)。恢复会照常
51
- * 回盘、归入「未分组」——界面上点之前就得知道,别等它从清单里消失才发现换了地方。
52
- * 缺席 = 工作区还在 / 没有工作区可判(与上面三个只读数同一条"不拿缺席冒充否"的规矩)。 */
53
- readonly workspaceMissing?: boolean;
54
- }
55
35
  export declare function createApiHandler(deps: DeleteDeps): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * **路由表**(ADR-0004):段名 → `{ 方法, 纯度, body, 处理 }`。一条记录,两个消费者。
3
+ *
4
+ * 此前"同一条请求该过哪几道判断"的知识散在两个函数里:`dispatch()` 里六个 `if` 各调一次
5
+ * `requireMethod`,`purityOf()` 里另一组 `if` 决定"要不要进到达序链",两处靠**注释**维持一致。
6
+ * 不一致的两种后果都不好在测试里撞见:
7
+ * - 该纯的判成写 ⇒ 一次探测/大正文读/扫描把删除按钮堵在串行链后面;
8
+ * - 该写的判成纯 ⇒ 写动作绕开到达序链,与对账交错,回收站出现"目录在、行是降级元数据"的世界;
9
+ * - body 该读没读 ⇒ 下游拿到 `null`;读了两遍 ⇒ 请求流已被消费(Node 的流只能消费一次)。
10
+ *
11
+ * 为什么这张表**不在** `shared/wire.ts`:处理函数会 import 动词与 `node:fs`,而浏览器半要 import
12
+ * `wire.ts`——值 import 会把 Node 半拖进客户端 bundle,正好撞上 `tsdown` 的纯度门。段名与动作仍住
13
+ * `wire.ts`(两半的共同契约),这张表只回答"怎么处理"。
14
+ */
15
+ import { ROUTES, type ApplyAction } from '../shared/wire.js';
16
+ import type { DeleteDeps } from '../verbs/delete-session.js';
17
+ /** 请求体:`json` 才读下来(GET 一律不读;读两遍会把流消费掉)。 */
18
+ export type BodyKind = 'none' | 'json';
19
+ /** 到达序:`pure` 不进链;`chained` 进链;函数形式给 `/apply` 用(`count` 是纯读)。 */
20
+ export type Purity = 'pure' | 'chained' | ((body: JsonBody) => 'pure' | 'chained');
21
+ type JsonBody = Record<string, unknown> | null;
22
+ export interface RouteSpec {
23
+ readonly method: 'GET' | 'POST';
24
+ readonly body: BodyKind;
25
+ readonly purity: Purity;
26
+ run(deps: DeleteDeps, body: JsonBody): Promise<unknown>;
27
+ }
28
+ export declare const APPLY_ACTIONS: readonly ApplyAction[];
29
+ /**
30
+ * 六个段名各一行。`Record<段名, RouteSpec>` 是闸:`wire.ts` 加了段名而这里没加,编译不过。
31
+ */
32
+ export declare const ROUTE_TABLE: Record<(typeof ROUTES)[keyof typeof ROUTES], RouteSpec>;
33
+ /** 段名 → 那一行;不认识的段名回 `undefined`(调用方抛 404,并带出原路径)。 */
34
+ export declare function routeOf(rest: string): RouteSpec | undefined;
35
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `GET /list` 那一行的装配:清册行(`TrashEntry`)→ 线上那一行(`TrashRowWire`),外加组视图的几个只读数。
3
+ *
4
+ * 它从 `api/dispatch.ts` 里搬出来,因为路由层的事是**传输**(栅栏、方法、纯度、信封),而"一行里放什么"
5
+ * 是这一层的事。「折进谁、谁露面」那条判据读 `trash/groups.ts`(ADR-0001),这里只做装配。
6
+ */
7
+ import type { DeleteDeps } from '../verbs/delete-session.js';
8
+ import type { TrashEntry } from '../trash/entry.js';
9
+ import type { TrashRowWire } from '../shared/wire.js';
10
+ /** 组视图那几个只读数的容器(行投影的第二个入参)。 */
11
+ type TrashRowReadouts = Pick<TrashRowWire, 'subagentCount' | 'subagentBytes' | 'strandedSubagents' | 'strandedBytes' | 'workspaceMissing'>;
12
+ /**
13
+ * 清册那一行 → 线上那一行。**显式字面量、不用 `...row`**:线上形状多一个必填字段时,这里编译不过,
14
+ * 于是"线上多了一格却没人填"不可能悄悄发生(`...row` 会把它带过去,看上去一切正常)。
15
+ */
16
+ export declare function toWire(row: TrashEntry, readouts?: TrashRowReadouts): TrashRowWire;
17
+ /**
18
+ * 把清册的全部行装配成「一行 = 一组」:孩子折进父那一行、不单独列(宿主的客户端本来就藏孩子,
19
+ * 我们若单列就会冒出一堆点不到的行);父行不在时孩子自己站出来占一行,否则它既看不见也清不掉。
20
+ * 这些派生值只加在这次回信上,**不落清册**——清册该记的是文件里真有的事实,而"还剩几个孩子"
21
+ * "工作区还在不在"是随盘上随时变的读数。取数失败照抛(响亮失败,不端一份少了这栏的清单当没事发生)。
22
+ */
23
+ export declare function assembleTrashRows(deps: DeleteDeps, rows: readonly TrashEntry[]): Promise<TrashRowWire[]>;
24
+ export {};
@@ -85,6 +85,18 @@ declare const ZH: {
85
85
  readonly 'trash.confirm.restore': "恢复 {count} 个会话?";
86
86
  readonly 'trash.confirm.restore.body': "这一批会回到它们各自的原项目目录,恢复到会话列表里。";
87
87
  readonly 'trash.confirm.restoreNow': "恢复";
88
+ readonly 'trash.orphans.banner': "回收站之外发现 {count} 个无主子会话";
89
+ readonly 'trash.orphans.meta': "共 {size},来自 {parents} 个失踪的父会话";
90
+ readonly 'trash.orphans.open': "查看并清理";
91
+ readonly 'trash.orphans.confirm': "清理 {count} 个无主子会话?";
92
+ readonly 'trash.orphans.body': "这些子会话的父会话已经不在会话列表或回收站里,清理后不可恢复。正在运行的子会话会被拒绝,不会被删除。";
93
+ readonly 'trash.orphans.group': "{project} · {count} 个 · {size}";
94
+ readonly 'trash.orphans.cacheNote': "本次只清理会话目录,不包含宿主投影缓存和附件。";
95
+ readonly 'trash.orphans.clean': "清理";
96
+ readonly 'trash.orphans.failure': "残留检查/清理失败:{message}";
97
+ readonly 'trash.orphans.cleanFailed': "{count} 组未清理:{message}";
98
+ readonly 'trash.orphans.cleanPartial': "已先删掉 {removed} 个会话目录({size})";
99
+ readonly 'trash.orphans.cleanGone': "有 {count} 组已不在了(可能已被别处清理,或父会话已恢复)";
88
100
  readonly 'detail.notSelected': "选中一条会话,看它讲了什么";
89
101
  readonly 'detail.loading': "正在读这条会话…";
90
102
  readonly 'detail.readFailed': "这条会话的正文读不出来";
@@ -3,7 +3,7 @@
3
3
  * 加上信封判别与注入面那一处「展开成裸值」的适配。
4
4
  *
5
5
  * 通道形状(裁决):真相/账本/判据全在宿主那边,Node 半用宿主**自己的 web server** 开一条
6
- * 插件专属前缀路由把五个段名(`list` / `ping` / `detail` / `apply` / `empty`)搬出来
6
+ * 插件专属前缀路由把六个段名(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)搬出来
7
7
  * (`src/api/dispatch.ts`),浏览器半同源调用。**不是重造后端**:
8
8
  * 路由层只做搬运与信封。为什么不用官方的 `remote.<namespace>`:那条路要**生成的 Typert 远程描述符**
9
9
  * 加一次 `$mount`,而盒内装配清单写死在 `dsh-api-remotes` 里、全宿主没有动态发现,第三方只能自挂自,
@@ -26,70 +26,30 @@
26
26
  * 所以到了组件面前只剩「裸值 / throw」两支,调用方只写 `if (!outcome.ok)`,正常流里没有 try/catch;
27
27
  * 非本信封形状的异常(网络层、TypeError…)也不丢原文(见 `failureOf`)。
28
28
  *
29
- * 面(`list` / `ping` / `detail` / `apply` / `empty`)与 Node 半路由的五个段名一一对应(段名本身的家是
30
- * `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`detail` 回单条正文,
29
+ * 面(`list` / `ping` / `orphans` / `detail` / `apply` / `empty`)与 Node 半路由的六个段名一一对应(段名本身的家是
30
+ * `src/shared/wire.ts`);`list` 回一行行的条目,`ping` 只回一个探针值,`orphans` 回一份**纯读**的
31
+ * 残留扫描报告,`detail` 回单条正文,
31
32
  * `apply` 回**一组逐条结果**,`empty` 回聚合回执。2026-10-17 之前是六个动作段(一条一段),
32
33
  * 批量只能由调用方循环拼——那正是「恢复 100 条很慢」的形状来源,收成一段之后这条通道不再有
33
- * "第二条执行路径"。
34
+ * "第二条执行路径"。**清理无主子会话也不另开一段**:它是 `apply` 的第五个动作,`orphans` 只负责
35
+ * "有多少、在哪、多大"。
34
36
  */
35
- import { type ApplyAction, type ApplyOutcome, type RemoteFailure, type RemoteResult } from '../shared/wire.js';
36
- /** 回收站条目(镜像 `src/trash/entry.ts` 的 `TrashEntry`;只读面)。 */
37
- export interface TrashEntryLike {
38
- id: string;
39
- sessionId: string;
40
- projectDir: string;
41
- workspaceId: string;
42
- wasArchived: boolean;
43
- deletedAt: number;
44
- title: string;
45
- sizeBytes: number;
46
- /** 组视图的只读数,**都由 `list` 现算现给、不住在清册里**(清册只记 `parentEntryId` 那件事实)。
47
- * 缺席 = 不知道或没有 ⇒ 界面上半句都不出现,也不许拿 0 冒充"没有"。
48
- * `subagentCount`:跟着这条父会话**一起进了回收站**的孩子数(清册里指向它的孩子行)。
49
- * `subagentBytes`:那些孩子的字节合计——孩子不单独列行,头部合计只加父那一格就会少报。
50
- * `strandedSubagents`:**还留在盘上没被带走**的孩子数(来源是宿主 `sessionQuery`),
51
- * 级联落地之前删掉的那些存量才非零。与 `subagentCount` 各说各的,不许互相冒充。
52
- * `workspaceMissing`:删除时记下的工作区已被删掉(宿主的「删除工作区」只删注册、保留日志)——
53
- * 恢复照常回盘,但会落在「未分组」。行上先说一句,别等它从清单里消失才发现换了地方。 */
54
- subagentCount?: number;
55
- subagentBytes?: number;
56
- strandedSubagents?: number;
57
- workspaceMissing?: boolean;
58
- }
59
- /** 彻底删除的回执(镜像 `PurgeResult`)。 */
60
- export interface PurgeResultLike {
61
- removed: number;
62
- freedBytes: number;
63
- }
64
- /** 详情正文里的一条消息(镜像 `TrashDetailMessage`;只读面)。 */
65
- export interface TrashDetailMessageLike {
66
- role: 'user' | 'assistant';
67
- time: number;
68
- text: string;
69
- truncated: boolean;
70
- }
37
+ import { type ApplyAction, type ApplyOutcome, type OrphanReportWire, type PurgeStatsWire, type RemoteFailure, type RemoteResult, type TrashDetailWire, type TrashRowWire } from '../shared/wire.js';
71
38
  /**
72
- * 回收站单条详情(镜像 `src/trash/detail.ts` 的 `TrashDetail`;只读面)。
39
+ * 回收站条目(`/list` 那一行)。**形状的家在 `shared/wire.ts`**(ADR-0003):这里只是旧名的别名,
40
+ * 所以浏览器半不再自己抄一遍字段;下面几处"只读面"的镜像同理。
41
+ */
42
+ export type TrashEntryLike = TrashRowWire;
43
+ /** 彻底删除的回执(`PurgeStatsWire`)。 */
44
+ export type PurgeResultLike = PurgeStatsWire;
45
+ /**
46
+ * 回收站单条详情(`TrashDetailWire`)。
73
47
  *
74
48
  * **线上只有"日志里读出来的那几件"**:`deletedAt` / `sizeBytes` / `wasArchived` / 清册 `title` 这些
75
49
  * 清册已有的字段一律不过线——组件按 `entryId` 与自己手上那份清单配对,这样"清单里的大小"和
76
50
  * "详情里的大小"永远是同一个数,不会因为两次读数不同而各说各话。
77
51
  */
78
- export interface TrashDetailLike {
79
- entryId: string;
80
- cwd: string;
81
- createdAt: number;
82
- lastActivity: number;
83
- logTitle: string;
84
- models: string[];
85
- turnCount: number;
86
- userMessageCount: number;
87
- assistantMessageCount: number;
88
- toolCallCount: number;
89
- injectedCount: number;
90
- messages: TrashDetailMessageLike[];
91
- omittedByPayloadCap: number;
92
- }
52
+ export type TrashDetailLike = TrashDetailWire;
93
53
  /** 任意异常 → 失败面。非带标记的失败(网络层、TypeError…)也保留原文,绝不吞;带标记的失败把
94
54
  * `details` 第三件一起带出。 */
95
55
  export declare function failureOf(reason: unknown): RemoteFailure;
@@ -115,11 +75,15 @@ export declare function callRemote<T>(run: () => Promise<T>): Promise<RemoteOutc
115
75
  * 每个方法 resolve 成 `RemoteResult<T>`。
116
76
  *
117
77
  * 2026-10-17 批量重构:写动词收成**一个 `apply`**(一段管所有动作、一组 id 进、逐条结果出),
118
- * 再加一段**纯探测** `ping` ⇒ 面是五段:`list` / `ping` / `detail` / `apply` / `empty`(原先六个动作段)。 */
78
+ * 再加一段**纯探测** `ping`;2026-10-19 又加一段**纯读扫描** `orphans` ⇒ 面是六段:
79
+ * `list` / `ping` / `orphans` / `detail` / `apply` / `empty`(原先六个动作段)。 */
119
80
  export interface RawSessionDeleteRemote {
120
81
  list(): Promise<RemoteResult<TrashEntryLike[]>>;
121
82
  /** 通道的"在不在":一次零成本的 GET(探测专用,见 `src/api/dispatch.ts` 的 `ping` 支)。 */
122
83
  ping(): Promise<RemoteResult<string>>;
84
+ /** 纯读:回收站之外的无主子会话扫描。它**不清理**——清理是 `apply` 的 `orphan-clean` 动作,
85
+ * 这一段只交回"有多少、在哪、多大"(读不到语料时报 `io`,调用方不许把它折成"零条残留")。 */
86
+ orphans(): Promise<RemoteResult<OrphanReportWire>>;
123
87
  detail(entryId: string): Promise<RemoteResult<TrashDetailLike>>;
124
88
  /** 一组 id 上的同一个动作。顶层信封的 `ok:false` 只表示**整次调用**没办成(路由自检、栅栏之外);
125
89
  * 单条动作的成败住在 `ApplyOutcome` 里——这是批量接口的判据,别在信封层找它。 */
@@ -144,38 +108,50 @@ export declare function createHttpRemote(prefix?: string): RawSessionDeleteRemot
144
108
  * 而"信封到不到"这件事一次零成本的 GET 就答得出来。
145
109
  */
146
110
  export declare function probeChannel(raw: RawSessionDeleteRemote): Promise<boolean>;
147
- /** `shared/wire.ts` 的 `ApplyOutcome` 从这里转出去——浏览器半只认 `remote.js` 这一扇门。 */
148
- export type { ApplyAction, ApplyOutcome } from '../shared/wire.js';
111
+ /** `shared/wire.ts` 的 `ApplyOutcome` 与那份扫描报告从这里转出去——浏览器半只认 `remote.js` 这一扇门。 */
112
+ export type { ApplyAction, ApplyOutcome, OrphanReportWire } from '../shared/wire.js';
149
113
  /** 一段管所有写动作的返回值(逐条)。`ApplySuccess<A>` = 某动作成功支(`value` 按动作收窄)。 */
150
114
  export type ApplySuccess<A extends ApplyAction> = Extract<ApplyOutcome, {
151
115
  action: A;
152
116
  ok: true;
153
117
  }>;
118
+ /**
119
+ * 注入面(`deps`)的 interface:**与六个段名一一对应的五个方法**(`ping` 只给探测用,留在 raw 层)。
120
+ *
121
+ * **不再有 N=1 的便捷方法**(ADR 复核候选 6):`delete` / `restore` / `countSubagents` 曾是同一段
122
+ * `/apply` 的三种写法,于是一个 `module` 里同一个动作有两条路(回收站面板的 deps 同时躺着
123
+ * `applyOutcomes` 与 `restore`)。现在它们收成一个自由函数 {@link applyOne}——要旧形状就显式调它,
124
+ * 而不是让 interface 多背三个方法。
125
+ */
154
126
  export interface SessionDeleteRemote {
155
127
  list(): Promise<TrashEntryLike[]>;
128
+ /** 回收站之外的无主子会话报告(纯读)。读不到就是 throw —— 界面上那是**一次失败的检查**,
129
+ * 不是"零条残留"(把 `io` 折成空报告等于把"不知道"说成"没有")。 */
130
+ scanOrphans(): Promise<OrphanReportWire>;
156
131
  detail(entryId: string): Promise<TrashDetailLike>;
157
132
  /** 一段管所有写动作(2026-10-17 批量重构):一次调用把整批办完,逐条结果原样交回组件。 */
158
133
  applyOutcomes(action: ApplyAction, ids: readonly string[]): Promise<ApplyOutcome[]>;
159
134
  /** 清空回收站(聚合回执)。 */
160
135
  emptyTrash(): Promise<PurgeResultLike>;
161
- /** 单条便捷包装 = `applyOutcomes` 的 N=1 支。`null` 是批量里"已被同批前一条带走"的幂等值;
162
- * 单条删除(N=1)永远不会命中它。 */
163
- delete(sessionId: string): Promise<TrashEntryLike | null>;
164
- restore(entryId: string): Promise<TrashEntryLike | null>;
165
- /** 这条会话在盘上有几个后代(删除确认框那句「含 N 个子会话」)。拿不到 ⇒ 0,那一栏整句不出现。 */
166
- countSubagents(sessionId: string): Promise<number>;
167
136
  }
137
+ /** 需要"单条便捷支"的组件只要这一段 deps(同一段 `/apply` 的 N=1 写法,见 {@link applyOne})。 */
138
+ export type OneShotDeps = Pick<SessionDeleteRemote, 'applyOutcomes'>;
139
+ /**
140
+ * **N=1 的 `apply`**:一个动作 + 一个 id → 那一条的成功支(`value` 按动作收窄)。
141
+ *
142
+ * 三条判据都在这一处:
143
+ * - **按发出去的那个 id** 取结果(取"第一条"会把别条的成败安在这条头上);
144
+ * - 一条没回是**通道的错**,不是"没做成"(后者住在 outcome 的 `ok:false` 里);
145
+ * - `null` 是批量里"已被同批前一条带走"的幂等值——单条调用(N=1)永远不会命中它。
146
+ */
147
+ export declare function applyOne<A extends ApplyAction>(deps: OneShotDeps, action: A, id: string): Promise<ApplySuccess<A>>;
168
148
  /**
169
- * 注入面的**唯一一处适配**:`RemoteResult` → 裸值,外加「一段批量」→「单条便捷面」的展开。
149
+ * 注入面的**唯一一处适配**:`RemoteResult` → 裸值。
170
150
  *
171
151
  * **为什么必须有这一步**:通道那头的调用 resolve 成 `RemoteResult<T>`(`{ ok: true, value }` /
172
152
  * `{ ok: false, error }`),失败是**返回**而不是 reject。组件面要的是「拿到值 / 抛异常」,
173
- * 所以在这里展开一次:`ok ? value : throw`。放在这一处而不是组件里,是为了让三个组件的 props 契约
174
- * (以及钉住它们的用例)保持裸值形状不变。
175
- *
176
- * **单条支里只有一条落地**:没有它(幽灵 id、或路由整次没办成)就 throw——"批量只回了一条别的"
177
- * 不是成功,也不是 0。
153
+ * 所以在这里展开一次:`ok ? value : throw`。
178
154
  */
179
155
  export declare function buildDeps(raw: RawSessionDeleteRemote): SessionDeleteRemote;
180
- /** 「删除」确认框的宿主需要的两件:提交删除的那条便捷包装,与删除前那一次计数(那句「含 N 个子会话」)。 */
181
- export type DeleteConfirmDeps = Pick<SessionDeleteRemote, 'delete' | 'countSubagents'>;
156
+ /** 「删除」确认框的宿主只需要那一段 `/apply`:提交删除与删除前那一次计数都走 {@link applyOne}。 */
157
+ export type DeleteConfirmDeps = OneShotDeps;
@@ -0,0 +1,39 @@
1
+ import { type Translate } from './copy.js';
2
+ import type { OrphanReportWire, TrashEntryLike } from './remote.js';
3
+ /** 待确认的动作:一条记录、整个回收站、或多选挑出来的一批(`ids` 已按清单顺序排好)。
4
+ * `purge` 与 `restore` 的确认框共用这一个壳——两者都是"点之前先说清这一批有哪些、要去哪"。 */
5
+ export type PendingAction = {
6
+ kind: 'entry';
7
+ entry: TrashEntryLike;
8
+ } | {
9
+ kind: 'all';
10
+ } | {
11
+ kind: 'entries';
12
+ ids: readonly string[];
13
+ } | {
14
+ kind: 'restore';
15
+ ids: readonly string[];
16
+ };
17
+ /**
18
+ * 两种批量动作共用这一个确认框壳:`purge`(不可恢复)与 `restore`(加法,但一批条目多、去向是
19
+ * 各自的旧项目目录,点之前也要过一道)。标题、正文、确认键的措辞各处一套;批量恢复把选中条目
20
+ * 列成卡片(标题 + 项目名——恢复的去向是**每条各自的**旧项目目录,逐条列出来是这句话的正文);
21
+ * 批量彻底删除只给条数——那批的"去哪"没有可说的,而"不可恢复"那句警告才是要读的。
22
+ */
23
+ export declare function TrashConfirm({ pending, restoreEntries, busy, t, onCancel, onConfirmPurge, onConfirmRestore, }: {
24
+ pending: PendingAction;
25
+ restoreEntries: readonly TrashEntryLike[];
26
+ busy: boolean;
27
+ t: Translate;
28
+ onCancel: () => void;
29
+ onConfirmPurge: () => void;
30
+ onConfirmRestore: (ids: readonly string[]) => void;
31
+ }): import("react").JSX.Element;
32
+ /** 无主子会话的清理确认框(2026-10-19)。载荷是刚扫出来的那份报告,界面不重算。 */
33
+ export declare function OrphanConfirm({ report, busy, t, onCancel, onConfirm, }: {
34
+ report: OrphanReportWire;
35
+ busy: boolean;
36
+ t: Translate;
37
+ onCancel: () => void;
38
+ onConfirm: () => void;
39
+ }): import("react").JSX.Element;
@@ -1,7 +1,9 @@
1
1
  import { type Translate } from './copy.js';
2
2
  import { type SessionDeleteRemote } from './remote.js';
3
- /** 批量重构(2026-10-17)后这份依赖面:两个读 + 一个批量口 + 清空 + 单条恢复的便捷包装。 */
4
- export type TrashPanelDeps = Pick<SessionDeleteRemote, 'list' | 'detail' | 'applyOutcomes' | 'emptyTrash' | 'restore'>;
3
+ /** 面板的依赖面:三个读(`list` / `detail` / `scanOrphans`)+ **一段 `apply`** + 清空。
4
+ * **没有第二条路**(架构复核候选 6):「恢复一条」也走 `apply`(N=1),面板不再同时吃
5
+ * `applyOutcomes` 与一个便捷的 `restore`。 `scanOrphans` 是回收站之外那批无主子会话的纯读报告。 */
6
+ export type TrashPanelDeps = Pick<SessionDeleteRemote, 'list' | 'detail' | 'applyOutcomes' | 'emptyTrash' | 'scanOrphans'>;
5
7
  export interface TrashPanelProps {
6
8
  deps: TrashPanelDeps;
7
9
  /** 文案(缺省 = 内置 zh,见 `copy.ts`:这不是"忘了传",而是"这次没有 locale 服务")。 */
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 回收站界面共用的**视图词汇**:卡片配方、危险红、项目名的念法。
3
+ *
4
+ * 它从 `trash-panel.tsx` 拆出来,因为清单面板与两个确认框(`trash-confirm.tsx`)都要用这几样——
5
+ * 拆之前它们同住一个文件、只能靠"同一个文件里的常量"共享。拆之后两处读的是同一份,不是两份抄写。
6
+ *
7
+ * **配色与圆角一律走 token,字号是字面量 px**(宿主同类面也这么写):卡片用宿主设置面的底层 token
8
+ * (`bg-layer-2` + `border-l4` + `radius-lg`),选中态换宿主"选中"那一档(见 `trash-panel.tsx` 头注)。
9
+ */
10
+ import type { CSSProperties } from 'react';
11
+ import type { Translate } from './copy.js';
12
+ import type { TrashEntryLike } from './remote.js';
13
+ /** 卡片配方:宿主设置面的 fill/stroke 底层 token + 官方 `lg` 圆角。 */
14
+ export declare const CARD: CSSProperties;
15
+ /** 选中行:底色与描边都换成宿主"选中"那一档(`trash-panel.tsx` 头注里有这两枚 token 的实测取值)。 */
16
+ export declare const CARD_SELECTED: CSSProperties;
17
+ /**
18
+ * **整页唯一那块实心红**的写法:`variant="primary"` + 在元素上覆盖 primary 那对填充 token。
19
+ * 宿主插件管理页的 danger 按钮就是这个写法(asar @21658274),所以 hover 仍走 `.primary:hover` 类。
20
+ */
21
+ export declare const BULK_DANGER: CSSProperties;
22
+ /**
23
+ * 项目名怎么念:宿主的 `projectKey` 把 cwd 里的 `/`、`\`、`:` 全折成**一个** `-` ⇒ 分隔符那部分有损、
24
+ * 拆不回真实路径;但**非分隔符那部分可逆**(`~XXXX` 十六进制码元,字面 `~` 编成 `~007E`)。
25
+ * 摘掉 `--…--` 裹边、还原这些转义,剩下的原样摆出来,完整串交给组头 `title`。
26
+ * 字母表只有一份:还原走 `shared/wire.ts`。
27
+ */
28
+ export declare function projectLabel(projectDir: string, t: Translate): string;
29
+ /** 清单上的一个项目组(组头 + 组内的条目,组内按删除时间倒序)。 */
30
+ export interface ProjectGroup {
31
+ projectDir: string;
32
+ label: string;
33
+ /** 组内最新一条的删除时间——组间排序的键。 */
34
+ latest: number;
35
+ entries: readonly TrashEntryLike[];
36
+ }
37
+ /** 按项目分组:组内按删除时间倒序,组间按各组最新一条倒序(刚删掉的那个项目排在最上面)。 */
38
+ export declare function groupByProject(entries: readonly TrashEntryLike[], t: Translate): ProjectGroup[];
@@ -1,6 +1,6 @@
1
1
  import { type Translate } from './copy.js';
2
- import { type SessionDeleteRemote } from './remote.js';
3
- export type UndoToastDeps = Pick<SessionDeleteRemote, 'restore' | 'applyOutcomes'>;
2
+ import { type OneShotDeps } from './remote.js';
3
+ export type UndoToastDeps = OneShotDeps;
4
4
  /**
5
5
  * 一次待撤销的事。两形(第十三批②起):单条删除(主语是会话名)/批量删除(主语是条数)。
6
6
  * 同一个 Toast 宿主、同一停留时长;撤销动作按形走——单条 `restore`,批量**一次** `applyOutcomes`。
@@ -0,0 +1,32 @@
1
+ import { type OrphanReportWire, type SessionDeleteRemote, type TrashEntryLike } from './remote.js';
2
+ import type { Translate } from './copy.js';
3
+ export type TrashQueryDeps = Pick<SessionDeleteRemote, 'list' | 'scanOrphans' | 'applyOutcomes' | 'emptyTrash'>;
4
+ export interface TrashQuery {
5
+ /** `null` = 还没读到(第一次读或读失败);与"空的"是两件事。 */
6
+ readonly entries: readonly TrashEntryLike[] | null;
7
+ readonly listFailure: string | null;
8
+ /** `null` = 还没扫到;失败时也置回 `null`(不拿空报告冒充干净)。 */
9
+ readonly orphanReport: OrphanReportWire | null;
10
+ readonly orphanFailure: string | null;
11
+ /** 「这一组已经不在」不是失败:单独一条中性提示。 */
12
+ readonly orphanNotice: string | null;
13
+ readonly orphanConfirm: boolean;
14
+ setOrphanConfirm(open: boolean): void;
15
+ /** 「查看并清理」:把上一条失败/中性提示清掉再开预览框(旧提示不该跟新一次预览并排出现)。 */
16
+ openOrphanPreview(): void;
17
+ /** 交回**这一次读数是否真的落地**:孤儿扫描吃的是对账后的清册,清单没读成就不能扫。 */
18
+ reload(): Promise<boolean>;
19
+ scanOrphans(): Promise<void>;
20
+ /** "重读一遍" = 先对账并读清单,再扫残留;清单没读成就不扫。 */
21
+ refresh(): Promise<void>;
22
+ /** 清理那批无主子会话(一段 `/apply` 的第五个动作),逐条结果落成"已不在了 / N 组未清理"。 */
23
+ cleanOrphans(): Promise<void>;
24
+ /** 清空回收站(聚合回执):成功就重读,失败就把原文摆到清单那块。交回是否成功。 */
25
+ emptyTrash(): Promise<boolean>;
26
+ }
27
+ export declare function useTrashQuery({ deps, t, around, }: {
28
+ deps: TrashQueryDeps;
29
+ t: Translate;
30
+ /** 把"这一趟还算忙"的一枚全局闸套在一次动作外(面板的 `busy`)。 */
31
+ around: <T>(task: () => Promise<T>) => Promise<T>;
32
+ }): TrashQuery;
@@ -0,0 +1,32 @@
1
+ import type { TrashEntryLike } from './remote.js';
2
+ import type { ProjectGroup } from './trash-view.js';
3
+ export interface TrashSelection {
4
+ /** 多选模式:入口在工具排(「多选」/「退出多选」),模式内点行说的是"勾/取消"。 */
5
+ readonly selectMode: boolean;
6
+ readonly checked: ReadonlySet<string>;
7
+ /** 按**清单从上往下**排好的勾选 id(页脚两颗与批量执行都读这一份)。 */
8
+ readonly checkedIds: readonly string[];
9
+ /** 键盘 ↑↓ 与下钻走这个顺序:**只含展开的组**(折叠的行不在 DOM 里,方向键别停过去)。 */
10
+ readonly ordered: readonly TrashEntryLike[];
11
+ readonly allCollapsed: boolean;
12
+ isCollapsed(projectDir: string): boolean;
13
+ enterSelect(): void;
14
+ exitSelect(): void;
15
+ toggleGroup(projectDir: string): void;
16
+ toggleAllGroups(): void;
17
+ toggleCheck(entryId: string): void;
18
+ /** 组头那颗:说的是"这个项目的全部会话",折叠起来的条目也算。勾满再点 = 整组取消。 */
19
+ toggleGroupCheck(group: {
20
+ entries: readonly TrashEntryLike[];
21
+ }): void;
22
+ groupFullyChecked(group: {
23
+ entries: readonly TrashEntryLike[];
24
+ }): boolean;
25
+ groupPartiallyChecked(group: {
26
+ entries: readonly TrashEntryLike[];
27
+ }): boolean;
28
+ }
29
+ export declare function useTrashSelection({ entries, groups, }: {
30
+ entries: readonly TrashEntryLike[] | null;
31
+ groups: readonly ProjectGroup[];
32
+ }): TrashSelection;
@@ -8,12 +8,13 @@ export declare const name = "@xrn1997/dsh-session-delete";
8
8
  * 三项都是在隔离数据根上实测注入成功、且本插件真正用到的宿主服务。三个**故意不写**的:
9
9
  * - `remote`:那是**浏览器半**的服务(asar `dsh-api-gateway/lib/client.js`:`super(ctx, "remote")`),
10
10
  * Node 半挂的是 `ctx.typertGateway`。把它写进 Node 半的 inject 会让插件永远 pending(同 `slots` 那次的形状)。
11
- * - `sessions` / `sessionProjectionCache`:前者本实现不用(活跃判据走 waterfall,见设计稿 §5);后者是**可选**服务,
12
- * 官方消费方也按可选读(`ctx.get('sessionProjectionCache')?.…`),inject 一个可能没挂的服务 = 整个插件 pending。
11
+ * - `sessions` / `sessionProjectionCache`:两者都按 `ctx.get(...)` **可选读**——`sessions` 只给 `evictLive`
12
+ * 用(活跃判据走 waterfall,见设计稿 §5;服务缺席回 `unknown`),`sessionProjectionCache` 是标题的尽力读面。
13
+ * inject 一个可能没挂的服务 = 整个插件 pending。
13
14
  * 它取不到时 `titleOf` 返回空串,正是契约允许的降级。
14
15
  *
15
16
  * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
16
- * 前缀路由,浏览器半同源 fetch 那五个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
17
+ * 前缀路由,浏览器半同源 fetch 那六个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
17
18
  * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
18
19
  * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
19
20
  */
@@ -29,7 +29,8 @@ export interface HostPort {
29
29
  * 冷会话没有活体可摘,但同样需要这一条——否则那一行要等到重连(`handleConnected` → 重列)才消失。 */
30
30
  announceRemoved(sessionId: string): Promise<void>;
31
31
  /** **如实通告**客户端"这个会话回来了":摘要取自宿主自己的会话列表,发 `api-session/added`。
32
- * 取不到摘要就不发(恢复本身已经完成)。 */
32
+ * 正常恢复走 {@link HostPort.announceRestoredBatch}(整批一次预取);这一条单条路是**删除组回滚**
33
+ * 用的(成员已经收过"已移除"、退回原处后补发)。取不到摘要就不发。 */
33
34
  announceRestored(sessionId: string): Promise<void>;
34
35
  /** 宿主会话表的一次预取:`[{ sessionId, summary }]`,`summary` 是宿主**原样**那一条。
35
36
  * 给 {@link announceRestoredBatch} 当 `known` 用——整批只拉一次表。服务没挂回空表,不抛。 */
@@ -50,6 +51,13 @@ export interface HostPort {
50
51
  /** 归档是 registry **全局**的,与工作区账目无关 ⇒ `workspaceId` 只是调用点对称用的可选入参
51
52
  * (未分组的会话没有工作区可传)。 */
52
53
  archive(sessionId: string, workspaceId?: string): Promise<void>;
54
+ /** 整个会话语料(live ∪ persisted)的只读身份表。**服务缺席回 `undefined`**,不拿空数组冒充
55
+ * “这个家没有会话”;扫描方必须把缺席当失败。 */
56
+ listSessions(): Promise<readonly SessionIdentity[] | undefined>;
57
+ /** 一次 BFS 点出多个父的全部后代;键按调用方传进来的写法回。
58
+ * `sessionQuery` 缺席时回 `{}`(与 delete 的降级一致)——写侧不能把这当成"盘上没有后代",
59
+ * 只能当成"这一趟看不见",残留留给 `GET /orphans` 兜底。 */
60
+ subagentMembersBatch(parentIds: readonly string[]): Promise<Record<string, readonly SubagentMember[]>>;
53
61
  /** 数一组父会话在盘上的**后代**条数(孩子与孙代都算——回收站清单上那个只读计数,宿主客户端把
54
62
  * `origin:"subagent"` 的行藏掉了 ⇒ 不说就没人知道删了父还剩多少)。键按**传进来的那个写法**回;
55
63
  * 没有后代的不表现在结果里(缺省即 0);`sessionQuery` 没挂回 `{}`,不抛。 */
@@ -60,6 +68,16 @@ export interface HostPort {
60
68
  readonly projectDir: string;
61
69
  }[]>;
62
70
  }
71
+ export interface SessionIdentity {
72
+ readonly sessionId: string;
73
+ readonly parentSessionId?: string;
74
+ readonly projectDir: string;
75
+ readonly subagent: boolean;
76
+ }
77
+ export interface SubagentMember {
78
+ readonly sessionId: string;
79
+ readonly projectDir: string;
80
+ }
63
81
  /** 无 cwd 的会话落在 `<sessions>/_no-cwd/`(asar `dsh-session-persistence-jsonl/lib/index.js`
64
82
  * 的 `projectDir(root, cwd)`:`if (cwd === void 0) return join(root, "_no-cwd");`)。
65
83
  * `projectDirOf` 与回收站对账(`src/trash/reconcile.ts` 重建条目)共用这一个字面量。 */
@@ -84,6 +102,8 @@ export declare const NO_CWD_PROJECT_DIR = "_no-cwd";
84
102
  * (手工放进回收站的目录,从日志 header 的 `cwd` 反推它原本属于哪个项目目录)——算法只有这一处家。
85
103
  */
86
104
  export declare function projectKey(cwd: string): string;
105
+ /** 会话 id 去掉 `session-` 前缀后的形状(同一个父在两代里写法不同,见 {@link HostPort.subagentCounts})。 */
106
+ export declare function bareSessionId(id: string): string;
87
107
  /** 这次失败是不是"账本里没那个工作区"(而不是另有故障)。恢复侧靠它把两种失败分开:
88
108
  * 前者按「未分组」收,后者照旧回滚报 `io`——不给真故障静默改归属的机会。 */
89
109
  export declare function isWorkspaceMissingError(error: unknown): boolean;
@@ -13,7 +13,7 @@ import { type RemoteFailure } from '../shared/wire.js';
13
13
  * - 其余(别域的怪 code、前缀外的因、非 Error 抛出物)⇒ `io`,message 保留原文。
14
14
  *
15
15
  * **返回的是线上形状、不是 `Error`**:这份东西唯一的去处是 `src/api/dispatch.ts` 的失败信封
16
- * (以及 `verbs/apply.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
16
+ * (以及 `verbs/batch.ts` 的逐条 `error`),而本仓的跨插件 import 红线也不许为此去引宿主的
17
17
  * `RemoteError` 类(实例也上不了线:信封只走这三件)。
18
18
  */
19
19
  export declare function toRemoteFailure(error: unknown): RemoteFailure;