dsh-session-ops 0.1.4 → 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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * `dsh-session-ops` Host 半边(插件入口)。
3
+ *
4
+ * 这个包把原先两个独立插件合并成**一个** DSH 插件:
5
+ * - `dsh-session-admin`(删除)→ {@link module:dsh-session-ops/admin};
6
+ * - `dsh-session-move`(搬迁)→ {@link module:dsh-session-ops/move}。
7
+ *
8
+ * 两个半边的代码是**逐字搬过来的**(见两个模块头部列出的差异清单),这里只做合并后的
9
+ * 那一层:一份配置 schema、一个活配置对象、一次 `loader/volatile-update` 订阅、
10
+ * 一个 `apply`,把两条路由与两个工具注册出去。
11
+ *
12
+ * ## 对外契约(合并后一条都不许变)
13
+ *
14
+ * - HTTP:`/session-admin/api/<method>` 与 `/session-move/api/<method>` 的方法名、
15
+ * 入参、`{ok:true,value}` / `{ok:false,error:{code,message}}` 响应形状;
16
+ * - 模型工具:`session_admin` 与 `session_move` 的名字、`parameters`、`output.schema`;
17
+ * - 文档与排障产物:`$DSH_HOME/logs/dsh-session-admin.log`(`[时间] 级别 正文`)与
18
+ * `$DSH_HOME/logs/dsh-session-move.log`(`时间 [级别] 正文`)两个文件与两种行格式;
19
+ * - 安全默认:`allowWorkspaceSessions` / `allowLiveSessions` / `moveAllowHosts` /
20
+ * `adminAllowHosts` / `memoryFollow` 一律落在安全的一侧(见 {@link Config})。
21
+ *
22
+ * ## 配置键的合并方式
23
+ *
24
+ * 两半原先各有一个 `allowHosts`,但语义不同(删除半边按 `Host` 头解析 authority,
25
+ * 搬迁半边按 socket 的 `remoteAddress` 精确匹配),所以**拆成两个键**而不是合并:
26
+ *
27
+ * | 旧键(dsh-session-admin) | 旧键(dsh-session-move) | 新键 |
28
+ * | --- | --- | --- |
29
+ * | `enabled` | `enabled` | `enabled`(两半共用一个总开关) |
30
+ * | `allowHosts` | — | `adminAllowHosts` |
31
+ * | — | `allowHosts` | `moveAllowHosts` |
32
+ * | — | `root` | `root` |
33
+ * | `headerDeleteButton` | — | `headerDeleteButton` |
34
+ * | `allowWorkspaceSessions` | — | `allowWorkspaceSessions` |
35
+ * | `allowLiveSessions` | — | `allowLiveSessions` |
36
+ * | `maxBatch` | — | `maxBatch` |
37
+ * | — | `refuseRunning` | `refuseRunning` |
38
+ * | — | `maxSessions` | `maxSessions` |
39
+ * | — | `memoryFollow` | `memoryFollow` |
40
+ *
41
+ * 只有 `enabled` 是**语义合并**(两半原先各一个,现在一个):任一旧值为 `false` 的
42
+ * 组合在新键下都会变成「全开或全关」,profile patch 要按新语义重写。
43
+ *
44
+ * @module dsh-session-ops
45
+ */
46
+ import z from '@deepseek-ai/schemastery';
47
+ import { ADMIN_API_PREFIX, createSessionAdmin, isInside, isTrustedRequest as isTrustedAdminRequest, mountAdmin } from './admin.js';
48
+ import { MOVE_API_PREFIX, isTrustedRequest as isTrustedMoveRequest, mountForkMembershipRepair, mountMove, sweepForkChildrenOnBoot } from './move.js';
49
+ import type { WorkspaceRegistryLike } from './store/registry.js';
50
+ import type { SessionPersistenceLike } from './move.js';
51
+ export { ADMIN_API_PREFIX, MOVE_API_PREFIX, createSessionAdmin, isInside, isTrustedAdminRequest, isTrustedMoveRequest, mountAdmin, mountForkMembershipRepair, mountMove, sweepForkChildrenOnBoot, };
52
+ /**
53
+ * 搬迁半边原来的公开名(`dsh-session-move` 的 `API_PREFIX = '/session-move/api'`)。
54
+ *
55
+ * 合并后两半各有一个前缀,明确的名字是 {@link ADMIN_API_PREFIX} / {@link MOVE_API_PREFIX};
56
+ * 这个别名留着,是为了让按 `dsh-session-move/lib/index.js` 写过 import 的脚本/测试不改也能跑。
57
+ */
58
+ export declare const API_PREFIX = "/session-move/api";
59
+ /** Cordis 插件名(profile 行 id 是 `session-toolbox`,两者刻意不同名)。 */
60
+ export declare const name = "dsh-session-ops";
61
+ /**
62
+ * 依赖的服务。**这不是可选的**:cordis 里没有声明 inject 就拿不到服务,
63
+ * `ctx.webServer` / `ctx.tools` / `ctx.sessions` / `ctx.sessionPersistence` 会静默是
64
+ * undefined——表现是日志照常打「mounted」但路由 404、工具根本没注册、守卫形同虚设。
65
+ *
66
+ * 两份清单的并集:删除半边要 `sessions`(Host 进程里挂着的会话),搬迁半边要
67
+ * `sessionPersistence`(会话根与写入句柄)。`workspaceRegistry` / `webServer` / `tools`
68
+ * 两边都要。
69
+ */
70
+ export declare const inject: string[];
71
+ /** 设置命名空间;与 client 半边 `settings.section` 的 id 必须一致。 */
72
+ export declare const SETTINGS_NS = "session-toolbox";
73
+ /**
74
+ * 插件配置(两份原配置的**并集**,键名与默认值见 {@link module:dsh-session-ops} 的表)。
75
+ *
76
+ * 键分两类,区别是**能不能在线改**:
77
+ *
78
+ * - **旋钮**:标了 `.volatile()` 的字段由 Loader 以活引用(`{ get() }`)交给 `apply`,
79
+ * 写盘后原地更新引用、再补发一次 `loader/volatile-update`。插件页上本行的「配置」
80
+ * 卡片写的就是这 7 个字段,落盘在 profile 的 `cordis.patch.yml` 里,**不重启、不重挂**。
81
+ * - **组合事实**:`enabled`(挂载期决定注册什么)、`root`(挂载期解析会话根)、
82
+ * `adminAllowHosts` / `moveAllowHosts`(挂载期决定 HTTP 信任栅栏)。刻意不标 volatile:
83
+ * 不进表单,要改就改 profile 的 patch(改完本行会重挂一次)。
84
+ *
85
+ * `description` 会随 schema 进入表单描述,是界面文案的权威来源。
86
+ */
87
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
88
+ /** 总开关(两半共用)。关掉后两条路由与两个工具都不注册。改它需要重挂本行(非 volatile)。 */
89
+ enabled: z<boolean, boolean, "defined">;
90
+ /** 会话根目录;留空则从 `sessionPersistence.root` 推断(jsonl 后端会暴露它)。搬迁半边用。 */
91
+ root: z<string, string, "defined">;
92
+ /**
93
+ * 删除半边的额外信任 authority(`host` 或 `host:port`,按 `Host` 头解析)。
94
+ * 原 `dsh-session-admin` 的 `allowHosts`。默认空——只信 loopback。
95
+ */
96
+ adminAllowHosts: z<string[], string[], "defined">;
97
+ /**
98
+ * 搬迁半边的额外信任 authority(`host` 或 `host:port`,按 socket 的 `remoteAddress`
99
+ * 精确匹配)。原 `dsh-session-move` 的 `allowHosts`。默认空——只信 loopback。
100
+ */
101
+ moveAllowHosts: z<string[], string[], "defined">;
102
+ /** 是否允许删「属于某个工作区」的会话。默认否——工作区会话通常还想留。 */
103
+ allowWorkspaceSessions: z<boolean, boolean, "volatile-defined">;
104
+ /** 是否允许删仍然挂在 Host 进程里的会话(正在浏览/正在跑)。默认否。 */
105
+ allowLiveSessions: z<boolean, boolean, "volatile-defined">;
106
+ /** 一次调用最多删多少个,防手滑。 */
107
+ maxBatch: z<number, number, "volatile-defined">;
108
+ /**
109
+ * 是否在会话标题栏显示「删除当前会话」的垃圾桶图标。**默认开启。**
110
+ *
111
+ * 形态刻意是「图标 + 二次确认」而不是带字面标签的按钮:标签会招来点击,而这里
112
+ * 删的正是你正在用的会话。图标不占字面位置,误点最多弹出一个确认框,离真正删除
113
+ * 还隔着一步。清理由右侧「会话管理」面板或设置页承担,那是显式挑选动作。
114
+ *
115
+ * 这是个 volatile 旋钮:浏览器半边在挂载时问一次 Host(`config` 接口),所以
116
+ * 改完**刷新页面**就生效——不需要重启 dsh web,也不需要重挂本插件。
117
+ */
118
+ headerDeleteButton: z<boolean, boolean, "volatile-defined">;
119
+ /** 是否拒绝搬"正在跑一轮"的会话。默认拒绝——搬运期间它的写入句柄可能落盘。 */
120
+ refuseRunning: z<boolean, boolean, "volatile-defined">;
121
+ /** `list` 一次最多返回多少条会话(浏览器半边还要筛选)。 */
122
+ maxSessions: z<number, number, "volatile-defined">;
123
+ /**
124
+ * 项目作用域记忆的条目要不要跟着会话走。**默认 off**。
125
+ *
126
+ * 项目记忆是**工作区共享**的(一次写入、那个工作区里所有会话都读),所以把一条
127
+ * 会话的条目拖到另一个工作区会改变那边的记忆视图——只有"这条会话本来就该在目标
128
+ * 工作区"(纠正错放)时才该这么做:
129
+ * - `off` :什么都不做。报告里仍给出**只读预览**(若开启会复制哪几条)。
130
+ * - `copy` :把该会话产出的条目(frontmatter `session:` 精确匹配)复制到目标
131
+ * 工作区,按文件名去重,源工作区不动。
132
+ * - `move` :同上,但复制后从源工作区移除(不可逆,谨慎)。
133
+ */
134
+ memoryFollow: z<string, string, "volatile-defined">;
135
+ /**
136
+ * 启动时是否做一轮「fork 子会话漏挂工作区」的定向扫描。
137
+ *
138
+ * 创建期的 `session/created` 补挂只挡住「Host fork 时就没挂」。记账写进去之后
139
+ * 又被抹掉的那一类(同机两个 Host 共用 `$DSH_HOME` 时,另一个进程用旧内存快照
140
+ * 重写 `storages/workspace.json`)只有启动期扫描能补回来,所以默认开启。
141
+ *
142
+ * 扫描范围收得很窄:只认带 `parentSession` 的普通 fork 子会话(排除 `origin:
143
+ * subagent` 与 `delegationDepth > 0`),且父会话已被某个工作区记账、子会话
144
+ * `cwd` 规范化后等于该工作区路径。刻意 ungrouped 的普通会话/迁移会话不碰。
145
+ */
146
+ repairForkChildrenOnBoot: z<boolean, boolean, "defined">;
147
+ }>>, Schemastery.ObjectT<NoInfer<{
148
+ /** 总开关(两半共用)。关掉后两条路由与两个工具都不注册。改它需要重挂本行(非 volatile)。 */
149
+ enabled: z<boolean, boolean, "defined">;
150
+ /** 会话根目录;留空则从 `sessionPersistence.root` 推断(jsonl 后端会暴露它)。搬迁半边用。 */
151
+ root: z<string, string, "defined">;
152
+ /**
153
+ * 删除半边的额外信任 authority(`host` 或 `host:port`,按 `Host` 头解析)。
154
+ * 原 `dsh-session-admin` 的 `allowHosts`。默认空——只信 loopback。
155
+ */
156
+ adminAllowHosts: z<string[], string[], "defined">;
157
+ /**
158
+ * 搬迁半边的额外信任 authority(`host` 或 `host:port`,按 socket 的 `remoteAddress`
159
+ * 精确匹配)。原 `dsh-session-move` 的 `allowHosts`。默认空——只信 loopback。
160
+ */
161
+ moveAllowHosts: z<string[], string[], "defined">;
162
+ /** 是否允许删「属于某个工作区」的会话。默认否——工作区会话通常还想留。 */
163
+ allowWorkspaceSessions: z<boolean, boolean, "volatile-defined">;
164
+ /** 是否允许删仍然挂在 Host 进程里的会话(正在浏览/正在跑)。默认否。 */
165
+ allowLiveSessions: z<boolean, boolean, "volatile-defined">;
166
+ /** 一次调用最多删多少个,防手滑。 */
167
+ maxBatch: z<number, number, "volatile-defined">;
168
+ /**
169
+ * 是否在会话标题栏显示「删除当前会话」的垃圾桶图标。**默认开启。**
170
+ *
171
+ * 形态刻意是「图标 + 二次确认」而不是带字面标签的按钮:标签会招来点击,而这里
172
+ * 删的正是你正在用的会话。图标不占字面位置,误点最多弹出一个确认框,离真正删除
173
+ * 还隔着一步。清理由右侧「会话管理」面板或设置页承担,那是显式挑选动作。
174
+ *
175
+ * 这是个 volatile 旋钮:浏览器半边在挂载时问一次 Host(`config` 接口),所以
176
+ * 改完**刷新页面**就生效——不需要重启 dsh web,也不需要重挂本插件。
177
+ */
178
+ headerDeleteButton: z<boolean, boolean, "volatile-defined">;
179
+ /** 是否拒绝搬"正在跑一轮"的会话。默认拒绝——搬运期间它的写入句柄可能落盘。 */
180
+ refuseRunning: z<boolean, boolean, "volatile-defined">;
181
+ /** `list` 一次最多返回多少条会话(浏览器半边还要筛选)。 */
182
+ maxSessions: z<number, number, "volatile-defined">;
183
+ /**
184
+ * 项目作用域记忆的条目要不要跟着会话走。**默认 off**。
185
+ *
186
+ * 项目记忆是**工作区共享**的(一次写入、那个工作区里所有会话都读),所以把一条
187
+ * 会话的条目拖到另一个工作区会改变那边的记忆视图——只有"这条会话本来就该在目标
188
+ * 工作区"(纠正错放)时才该这么做:
189
+ * - `off` :什么都不做。报告里仍给出**只读预览**(若开启会复制哪几条)。
190
+ * - `copy` :把该会话产出的条目(frontmatter `session:` 精确匹配)复制到目标
191
+ * 工作区,按文件名去重,源工作区不动。
192
+ * - `move` :同上,但复制后从源工作区移除(不可逆,谨慎)。
193
+ */
194
+ memoryFollow: z<string, string, "volatile-defined">;
195
+ /**
196
+ * 启动时是否做一轮「fork 子会话漏挂工作区」的定向扫描。
197
+ *
198
+ * 创建期的 `session/created` 补挂只挡住「Host fork 时就没挂」。记账写进去之后
199
+ * 又被抹掉的那一类(同机两个 Host 共用 `$DSH_HOME` 时,另一个进程用旧内存快照
200
+ * 重写 `storages/workspace.json`)只有启动期扫描能补回来,所以默认开启。
201
+ *
202
+ * 扫描范围收得很窄:只认带 `parentSession` 的普通 fork 子会话(排除 `origin:
203
+ * subagent` 与 `delegationDepth > 0`),且父会话已被某个工作区记账、子会话
204
+ * `cwd` 规范化后等于该工作区路径。刻意 ungrouped 的普通会话/迁移会话不碰。
205
+ */
206
+ repairForkChildrenOnBoot: z<boolean, boolean, "defined">;
207
+ }>>, "plain">;
208
+ /**
209
+ * 解包后的插件配置(键名与 {@link Config} 一一对应,值都已收敛成普通值)。
210
+ *
211
+ * 手写而不是从 schemastery 推导:推到 `Config` 的完整类型会把 `.volatile()` 的
212
+ * `Volatile<T>` 包装也带出来,而 `apply` 里看到的永远是解包后的普通值。
213
+ */
214
+ export interface SessionOpsConfig {
215
+ enabled: boolean;
216
+ root: string;
217
+ adminAllowHosts: string[];
218
+ moveAllowHosts: string[];
219
+ allowWorkspaceSessions: boolean;
220
+ allowLiveSessions: boolean;
221
+ maxBatch: number;
222
+ headerDeleteButton: boolean;
223
+ refuseRunning: boolean;
224
+ maxSessions: number;
225
+ memoryFollow: string;
226
+ repairForkChildrenOnBoot: boolean;
227
+ }
228
+ /**
229
+ * 解包 entry 配置里的 `.volatile()` 活引用,并交给 schema 校验补默认值。
230
+ *
231
+ * 实现在 `lib/store/host.js`:先解包输入、再校验、最后**再解包一次**——
232
+ * schemastery 对 `.volatile()` 字段输出的也是活引用,不收敛的话读到的就是 `{ get() }`
233
+ * 对象(HTTP 面上会变成一个空对象)。
234
+ * @param raw - entry 配置或普通配置对象。
235
+ * @returns 普通值配置。
236
+ */
237
+ export declare function resolveConfig(raw?: Record<string, unknown>): SessionOpsConfig;
238
+ /**
239
+ * Host cordis 上下文里本入口用到的那部分。
240
+ *
241
+ * DSH/Cordis 的 `Context` 在编译期拿不到(本包没有把 `@deepseek-ai/cordis` 列为依赖),
242
+ * 而这里只需要这几个成员:`inject` 声明过的服务、`ctx.get()`、`ctx.effect()`、`ctx.on()`。
243
+ */
244
+ export interface SessionOpsContext {
245
+ logger?: Record<string, ((message: string) => unknown) | undefined> | undefined;
246
+ on: (event: string, handler: (...args: any[]) => unknown) => unknown;
247
+ effect: (fn: () => unknown, label?: string) => unknown;
248
+ get: (name: string) => any;
249
+ webServer: {
250
+ register(options: unknown): unknown;
251
+ };
252
+ tools: {
253
+ register(definition: unknown): unknown;
254
+ };
255
+ workspaceRegistry: WorkspaceRegistryLike;
256
+ sessionPersistence?: SessionPersistenceLike | undefined;
257
+ }
258
+ /**
259
+ * 挂载插件:两条路由 + 两个工具。
260
+ *
261
+ * 两个半边共用**同一个活配置对象**(见下),各自的信任栅栏读各自的那个键
262
+ * (`adminAllowHosts` / `moveAllowHosts`),所以 volatile 更新只需要在这里重读一次。
263
+ *
264
+ * 日志刻意保留两份、两种行格式:`$DSH_HOME/logs/dsh-session-admin.log`
265
+ * (`[时间] 级别 正文`)与 `$DSH_HOME/logs/dsh-session-move.log`(`时间 [级别] 正文`)
266
+ * 是合并前就存在的排障产物,合并后依然按各自的格式写各自那份。
267
+ *
268
+ * @param ctx - Host cordis 上下文(已 inject 五个服务)。
269
+ * @param rawConfig - 部署层配置。
270
+ * @returns 无。
271
+ */
272
+ export declare function apply(ctx: SessionOpsContext, rawConfig?: Record<string, unknown>): void;
273
+ /**
274
+ * 会话存储原语全量再导出(`lib/store/` 的五个模块):
275
+ * `dshHome`、`encodeSegment`、`readSessionHeader`、`hasZstd`、`decodeProjectKey` 等。
276
+ *
277
+ * 原来的会话存储共享库根入口就是这个形状;合并成单包后保留同一份公开面,
278
+ * 外部按包名导入的脚本(如 dsh-open-in-dsh)只改包名即可,不必换符号名。
279
+ * 需要显式分层时也可以用子路径 `dsh-session-ops/store`(见 package.json exports)。
280
+ */
281
+ export * from './store/index.js';
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 项目作用域记忆的「随会话走」支持(**可选,默认关**)。
3
+ *
4
+ * 背景:`dsh-cross-memory` 的项目作用域是**工作区级**的 —— 存到
5
+ * `$DSH_HOME/memories/projects/<slug>/entries/<id>.md`,slug 由工作区根目录派生
6
+ * (`slugify(basename)`,只取项目名、与机器路径无关,见其 storage.js)。
7
+ * 所以搬会话**不需要**搬记忆:会话换了 cwd,作用域自然跟到目标工作区。
8
+ * 但"这条会话在源工作区下写下的条目"留在源工作区,如果这次搬迁属于**纠正错放**
9
+ * (会话本来就该在目标工作区),那些条目也该一起过去。本模块只负责这一件事。
10
+ *
11
+ * 两条设计原则:
12
+ * 1. **默认不做**。项目记忆是工作区共享的,把单条会话的条目拖过去会改变另一个
13
+ * 工作区的记忆视图——这种事必须显式开启(`memoryFollow: 'copy' | 'move'`)。
14
+ * 2. **文件级、可逆、可预演**。`dsh-cross-memory` 的存储契约写明「条目文件才是真相源,
15
+ * 索引是可重建的缓存,手改/删除索引会在下次读取时自愈」,所以复制 `.md` 文件
16
+ * 就是受支持的写入方式,也不必依赖它的运行时。
17
+ *
18
+ * @module dsh-session-ops/memory
19
+ */
20
+ /** 一个已存在的项目记忆目录。 */
21
+ export interface ProjectMemoryDir {
22
+ slug: string;
23
+ root: string;
24
+ descriptorFile: string;
25
+ dir: string;
26
+ }
27
+ /** 一条被选中的记忆条目文件。 */
28
+ export interface MemoryFollowEntry {
29
+ id: string;
30
+ title: string;
31
+ sourceFile: string;
32
+ targetFile: string;
33
+ existsInTarget: boolean;
34
+ }
35
+ /** {@link planMemoryFollow} 的计划(**只读**)。 */
36
+ export interface MemoryFollowPlan {
37
+ projectsDir: string;
38
+ source: {
39
+ slug: string;
40
+ root: string;
41
+ dir: string;
42
+ } | null;
43
+ target: {
44
+ slug: string;
45
+ root: string;
46
+ dir: string;
47
+ descriptorFile: string;
48
+ existed: boolean;
49
+ planned?: boolean;
50
+ };
51
+ sessionId: string;
52
+ totalInSource: number;
53
+ matched: MemoryFollowEntry[];
54
+ }
55
+ /** {@link planMemoryFollow} 的选项。 */
56
+ export interface MemoryFollowOptions {
57
+ dshHome: string;
58
+ sessionId: string;
59
+ fromCwd: string;
60
+ toCwd: string;
61
+ }
62
+ /** {@link applyMemoryFollow} 的结果。 */
63
+ export interface MemoryFollowOutcome {
64
+ mode: string;
65
+ copied: number;
66
+ skipped: number;
67
+ removed: number;
68
+ files: string[];
69
+ removeError?: string;
70
+ }
71
+ /** 与 dsh-cross-memory/lib/storage.js 的 slugify 逐字一致。 */
72
+ export declare function slugify(value: unknown): string;
73
+ /**
74
+ * 与 dsh-cross-memory/lib/storage.js 的 projectSlug 逐字一致:**只取项目名**,
75
+ * 不再拼 `sha1(resolve(root))` 后缀。dsh-cross-memory 已把项目 slug 改成
76
+ * 「一台机器上算出来、另一台也对得上」的名字派生(旧的后缀方案会把同一个
77
+ * 项目按机器拆成多个目录,迁移逻辑见其 storage.ts 的 LEGACY_PROJECT_SLUG)。
78
+ */
79
+ export declare function projectSlug(root: string): string;
80
+ /** `$DSH_HOME/memories/projects`。 */
81
+ export declare function projectsDirOf(dshHome: string): string;
82
+ /**
83
+ * 在 projects 目录里按 root 找已存在的项目(优先命中已有 slug,避免算出第二个目录)。
84
+ * @param projectsDir - `…/memories/projects`。
85
+ * @param root - 工作区根目录。
86
+ * @returns `{slug, root, descriptorFile, dir}`,没有时 `undefined`。
87
+ */
88
+ export declare function findProjectByRoot(projectsDir: string, root: string): ProjectMemoryDir | undefined;
89
+ /** 极简 frontmatter 解析:只认 `key: value` 单行(dsh-cross-memory 的条目就是这种形状)。 */
90
+ export declare function parseFrontmatter(text: string): Record<string, string>;
91
+ /**
92
+ * 计算一次「记忆随会话走」的计划。**只读**。
93
+ *
94
+ * @param options - `{dshHome, sessionId, fromCwd, toCwd}`。
95
+ * @returns 计划:源/目标项目、匹配到的条目(按 frontmatter 的 `session:` 归属过滤)。
96
+ */
97
+ export declare function planMemoryFollow(options: MemoryFollowOptions): MemoryFollowPlan;
98
+ /**
99
+ * 执行计划。`copy` 只加不删;`move` 复制后从源项目移除(= 纠正错放)。
100
+ * 目标已有同名条目时跳过(不覆盖、按名去重)。
101
+ *
102
+ * @param plan - {@link planMemoryFollow} 的返回值。
103
+ * @param mode - `'copy'` 或 `'move'`。
104
+ * @returns `{mode, copied, skipped, removed, files}`。
105
+ */
106
+ export declare function applyMemoryFollow(plan: MemoryFollowPlan, mode: string): MemoryFollowOutcome;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `dsh-session-ops` 的**搬迁半边**(原 `dsh-session-move` 插件的 Host 半边)。
3
+ *
4
+ * 这一半的代码搬自原 `dsh-session-move` 的 `lib/index.js`,差异只有四处:
5
+ * - 插件 entry 的 `name` / `inject` / `Config` / `resolveConfig` / `apply` 上移到
6
+ * `lib/index.js`(合并后只有一个 entry、一份配置 schema、一个 `apply`);
7
+ * - `API_PREFIX` 改名 `MOVE_API_PREFIX`(删除半边占着 `ADMIN_API_PREFIX`);
8
+ * - 配置键 `allowHosts` 改名 `moveAllowHosts`(与 `adminAllowHosts` 语义不同);
9
+ * - 原 `apply` 里"建配置/建日志/订阅 volatile/看 enabled"那段上移到入口,剩下的接线
10
+ * 成为 `mountMove(ctx, config, log)`。
11
+ * 其余一字未动(`createMover`、`/session-move/api` 的方法表、`session_move` 工具定义、
12
+ * socket 版信任栅栏、注释)。`./engine.js` / `./memory.js` / `./sessionlog.js` 也原样搬来。
13
+ *
14
+ * 能力:把一条已持久化的会话搬到另一个工作区——**真搬迁**:
15
+ * 1. 会话日志搬到目标工作区的 project 目录,header 的 `cwd` 改写成目标路径;
16
+ * 2. 目标工作区 `attachSession`、原工作区 `detachSession`(工作区注册表成员随动);
17
+ * 3. 若该会话此刻正被 Host 持有(`ctx.sessions` 里挂着),同时改写内存里的
18
+ * `session.header.cwd` 与持久化写入句柄的 header,让它之后继续往新位置写。
19
+ *
20
+ * 一处硬边界:**正在跑一轮的会话一律拒绝**(`refuseRunning` 默认 true)。它的写入句柄
21
+ * 随时可能落盘,搬家窗口里挪目录会让这一轮的事件写到一个不存在的位置(ENOENT)。
22
+ *
23
+ * 会话存储的公共知识(路径命名、帧读写、注册表成员/归档集合、「正在跑」判定、
24
+ * 宿主与 HTTP 面的底座)在 `lib/store/` 里;本文件只保留搬迁策略本身。
25
+ *
26
+ * @module dsh-session-ops/move
27
+ */
28
+ import type { HttpRequest } from './store/http.js';
29
+ import { dshHome } from './store/host.js';
30
+ import type { LogFn } from './store/host.js';
31
+ import type { PersistenceLike, WorkspaceEntity, WorkspaceRegistryLike } from './store/registry.js';
32
+ import type { MoveReport } from './engine.js';
33
+ import type { MemoryFollowOutcome } from './memory.js';
34
+ import type { SessionOpsConfig } from './index.js';
35
+ /** HTTP 路由前缀(两半各一条,合并后仍然分开)。 */
36
+ export declare const MOVE_API_PREFIX = "/session-move/api";
37
+ /**
38
+ * `$DSH_HOME`(环境变量优先,默认 `~/.dsh`)。
39
+ *
40
+ * 实现在 `lib/store/host.js`——它与删除半边用的是同一份;
41
+ * 这里再导出,保持本仓库的公开面不变。
42
+ */
43
+ export { dshHome };
44
+ /**
45
+ * loopback 或显式白名单 authority 才放行,并额外拒绝浏览器侧的跨站 / 跨源请求。
46
+ *
47
+ * 原先这份栅栏只按 socket 的 `remoteAddress` 判断回环、`allowHosts` 只做精确字符串
48
+ * 匹配,**不看 `sec-fetch-site` / `Origin`**——恶意页面从任意站点 POST 到本机
49
+ * `127.0.0.1:3080` 时,socket 地址同样是回环,只看 `remoteAddress` 拦不住。
50
+ * 2026-09-25 与 `dsh-session-admin` 那份栅栏对齐(那条缺口已补),差异只剩
51
+ * 「本份以 `remoteAddress` 为回环判据、admin 以 `Host` 头为判据」。
52
+ * 共享包只提供路由骨架(`createApiHandler`),栅栏以回调形式传进去。
53
+ */
54
+ export declare function isTrustedRequest(req: HttpRequest, allowHosts: string[]): boolean;
55
+ /** 持久化后端 `list()` 返回的一条会话快照。 */
56
+ export interface SessionSnapshotLike {
57
+ id?: unknown;
58
+ header: Record<string, unknown>;
59
+ }
60
+ /** 持久化后端 `open()` 返回的读句柄。 */
61
+ export interface PersistenceHandleLike {
62
+ header: Record<string, unknown>;
63
+ inheritedEventCount?: unknown;
64
+ read(): Promise<{
65
+ events?: unknown[];
66
+ } | undefined>;
67
+ close(): Promise<unknown>;
68
+ }
69
+ /** 搬迁半边用到的持久化后端能力(`ctx.sessionPersistence` 的子集)。 */
70
+ export interface SessionPersistenceLike extends PersistenceLike {
71
+ list: () => Promise<SessionSnapshotLike[]>;
72
+ open: (sessionId: string, mode: string) => Promise<PersistenceHandleLike>;
73
+ }
74
+ /** `list` 快照里的一行工作区。 */
75
+ export interface WorkspaceSummary {
76
+ id: string;
77
+ title: string;
78
+ path: string;
79
+ visible: number;
80
+ accounted: number;
81
+ }
82
+ /** `list` 快照里的一条会话。 */
83
+ export interface SessionRow {
84
+ id: string;
85
+ cwd: string | null;
86
+ createdAt: number | null;
87
+ resident: boolean;
88
+ running: string | null;
89
+ hasWriteHandle: boolean;
90
+ archived: boolean;
91
+ workspaceId: string | null;
92
+ workspaceTitle: string | null;
93
+ movable: boolean;
94
+ }
95
+ /** {@link Mover.list} 的返回。 */
96
+ export interface ListSnapshot {
97
+ root: string;
98
+ compression: string;
99
+ zstd: boolean;
100
+ workspaces: WorkspaceSummary[];
101
+ sessions: SessionRow[];
102
+ counts: {
103
+ stored: number;
104
+ resident: number;
105
+ workspaces: number;
106
+ };
107
+ }
108
+ /** `mover.move` 的入参(HTTP / 工具都把浏览器或模型给的对象原样递进来)。 */
109
+ export interface MoveRequest {
110
+ sessionId?: unknown;
111
+ targetWorkspace?: unknown;
112
+ dryRun?: unknown;
113
+ }
114
+ /** 计划摘要在报告里的形状。 */
115
+ export interface MovePlanSummary {
116
+ generation: string;
117
+ version: number;
118
+ compressed: boolean;
119
+ bytesBefore: number;
120
+ bytesAfter: number;
121
+ frames: number | null;
122
+ sourcePath: string;
123
+ targetPath: string;
124
+ leaseFile: string | null;
125
+ leftInSourceDir: string[];
126
+ otherGenerations: string[];
127
+ }
128
+ /** 记忆跟随预览/结果摘要在报告里的形状。 */
129
+ export interface MemoryFollowSummary {
130
+ mode: string;
131
+ sourceSlug?: string | null;
132
+ targetSlug?: string;
133
+ targetHadProject?: boolean;
134
+ sourceEntries?: number;
135
+ matched?: number;
136
+ titles?: string[];
137
+ alreadyInTarget?: number;
138
+ note?: string;
139
+ error?: string;
140
+ applied?: MemoryFollowOutcome;
141
+ applyError?: string;
142
+ }
143
+ /** `mover.verify` 的读回结果。 */
144
+ export interface VerifyReadback {
145
+ headerCwd: string | null;
146
+ inheritedEventCount: number;
147
+ events: number | null;
148
+ }
149
+ /** {@link Mover.move} 的完整报告。字段按路径可选(noop / attach-only / dry-run)。 */
150
+ export interface MoveActionReport {
151
+ sessionId: string;
152
+ from: string;
153
+ to: string;
154
+ workspace: {
155
+ id: string;
156
+ title: string;
157
+ };
158
+ dryRun: boolean;
159
+ running: string | null;
160
+ noop?: boolean;
161
+ note?: string;
162
+ attachOnly?: boolean;
163
+ dryRunOnly?: boolean;
164
+ plan?: MovePlanSummary;
165
+ memoryFollow?: MemoryFollowSummary;
166
+ storage?: MoveReport;
167
+ handleFlushError?: string;
168
+ writeHandleRedirected?: boolean;
169
+ liveHeaderUpdated?: boolean;
170
+ registryHeaderCacheUpdated?: boolean;
171
+ attachedTo?: string;
172
+ detachedFrom?: string;
173
+ detachError?: string;
174
+ dshReadback?: VerifyReadback;
175
+ dshReadbackError?: string;
176
+ memoryScope?: string;
177
+ ok?: boolean;
178
+ }
179
+ /** `list` / `move` / `verify` 三个入口(`createMover` 的返回)。 */
180
+ export interface Mover {
181
+ list: () => Promise<ListSnapshot>;
182
+ move: (request: MoveRequest) => Promise<MoveActionReport>;
183
+ verify: (sessionId: string) => Promise<VerifyReadback>;
184
+ rootOf: () => string;
185
+ compressionOf: () => string;
186
+ resolveWorkspace: (key: unknown) => WorkspaceEntity | undefined;
187
+ }
188
+ /** {@link createMover} 用到的 Host 上下文(最小本地接口,见 `src/index.ts` 的说明)。 */
189
+ export interface MoveContext {
190
+ sessionPersistence?: SessionPersistenceLike | undefined;
191
+ workspaceRegistry: WorkspaceRegistryLike;
192
+ get: (name: string) => any;
193
+ webServer: {
194
+ register(options: unknown): unknown;
195
+ };
196
+ tools: {
197
+ register(definition: unknown): unknown;
198
+ };
199
+ effect: (fn: () => unknown, label?: string) => unknown;
200
+ }
201
+ /**
202
+ * 挂载搬迁半边:路由 + 工具。
203
+ *
204
+ * 与删除半边刻意不同的两点(原样保留的理由):
205
+ * - 信任栅栏看 socket 的 `remoteAddress`(`isTrustedRequest`),`allowHosts` 只做精确
206
+ * 字符串匹配;删除半边看 `Host` 头;
207
+ * - 请求体按**严格模式**读(`requireObject: true`,非普通对象一律 400)。
208
+ *
209
+ * @param ctx - Host cordis 上下文(`webServer` / `tools` / `sessionPersistence` /
210
+ * `workspaceRegistry`;`sessions` / `agents` 是可选事实源,用 `ctx.get` 取)。
211
+ * @param config - 已解包的**活配置对象**(身份由入口保持,volatile 更新时原地覆盖)。
212
+ * @param log - `(level, message) => void`(入口传的是 move 那套行格式的文件日志)。
213
+ * @returns 无。
214
+ */
215
+ export declare function mountMove(ctx: MoveContext, config: SessionOpsConfig, log: LogFn): void;
216
+ /** 成员自愈扫描的汇总。 */
217
+ export interface ForkSweepReport {
218
+ checked: number;
219
+ attached: number;
220
+ skipped?: string;
221
+ }
222
+ /** 成员自愈器(创建期监听与启动期扫描共用)。 */
223
+ export interface ForkMembershipRepairer {
224
+ repair: (sessionId: string, header: Record<string, unknown> | undefined, reason: string) => Promise<void>;
225
+ ownerOf: (sessionId: string) => WorkspaceEntity | undefined;
226
+ }
227
+ /**
228
+ * 成员自愈用到的 Host 上下文(`workspaceRegistry` 可缺:缺了就跳过自愈)。
229
+ */
230
+ export interface RepairContext {
231
+ workspaceRegistry?: WorkspaceRegistryLike | undefined;
232
+ sessionPersistence?: SessionPersistenceLike | undefined;
233
+ on: (event: string, handler: (...args: any[]) => unknown) => unknown;
234
+ }
235
+ /**
236
+ * 启动期的成员自愈扫描:把**已经丢掉记账**的 fork 子会话补回去。
237
+ *
238
+ * ## 为什么创建期补挂不够(实测的第二条证据链)
239
+ *
240
+ * `session/created` 那一刻补挂只挡住「Host fork 时就没挂」这一种。实测中有些 fork
241
+ * 在 Host 侧确实走了 `workspace.attachSession(childId)`(RPC 返回成功、插件侧没有
242
+ * `fork 失败` 日志),但子会话最终仍不在任何工作区的 `sessionIds` 里——也就是说
243
+ * 记账**写进去之后又没了**。同一个时间窗里,父会话的归档也没落住。
244
+ *
245
+ * 能同时抹掉「新 attach」与「归档集合」的只有一件事:**另一个 Host 进程用自己内存里
246
+ * 的旧快照重写了 `$DSH_HOME/storages/workspace.json`**。存储层是整文件原子替换、
247
+ * 无跨进程锁(`dsh-storage-json` 的注释写的就是「一个 unit 文件只有一个写者,后写
248
+ * 覆盖先写是对的」),而同一台机器上同时跑两个 Host、共用同一个 `~/.dsh` 就会出现
249
+ * 这类丢失。这类丢失发生在 attach 之后,创建期的补挂拦不住。
250
+ *
251
+ * 所以这里补一道**启动期**的定向扫描:只处理 fork 子会话(`isForkChild`),且仍然
252
+ * 要求「父会话已被工作区记账 + 子会话 cwd 规范化后等于该工作区路径」;不碰子代理、
253
+ * 不碰没有父会话的普通会话,也不做全量 attach,避免改写用户刻意留着的 ungrouped。
254
+ *
255
+ * 幂等:已记账直接跳过;失败只记日志,绝不让插件挂载失败。
256
+ *
257
+ * @param ctx - Host cordis 上下文(需要 `workspaceRegistry` 与 `sessionPersistence`)。
258
+ * @param config - 活配置对象;`repairForkChildrenOnBoot === false` 时跳过整轮扫描。
259
+ * @param log - `(level, message) => void`。
260
+ * @param shared - 与创建期监听器共享的 repairer(`mountForkMembershipRepair` 内部用)。
261
+ * @returns 扫描结束(含跳过)后 resolve 的报告;永不 reject。
262
+ */
263
+ export declare function sweepForkChildrenOnBoot(ctx: RepairContext, config?: Partial<SessionOpsConfig>, log?: LogFn, shared?: ForkMembershipRepairer): Promise<ForkSweepReport>;
264
+ /**
265
+ * 挂载 fork 子会话的成员自愈:创建期监听 + 启动期定向扫描。
266
+ *
267
+ * @param ctx - Host cordis 上下文。
268
+ * @param config - 活配置对象。
269
+ * @param log - `(level, message) => void`。
270
+ * @returns 启动期扫描的 Promise(Host/cordis 忽略;测试可 await 以确定性观察结果)。
271
+ */
272
+ export declare function mountForkMembershipRepair(ctx: RepairContext, config: Partial<SessionOpsConfig>, log: LogFn): Promise<ForkSweepReport> | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 会话日志的帧级读写原语 —— **实现在本包的 `lib/store/` 里**(`dsh-session-ops`)。
3
+ *
4
+ * 这个文件现在只是一层**历史路径的门面**:`./sessionlog.js` 曾经是本插件私有的
5
+ * 实现(从 dsh-sync 移植),而删除半边那边重复着同一套知识的另一半
6
+ * (`decodeProjectKey`)。两个插件合并成 `dsh-session-ops` 之后:
7
+ * - 实现只有一份,规则与边界都写在 `lib/store/paths.js` / `lib/store/frames.js`
8
+ * 的模块注释里;
9
+ * - 这里的模块路径保留下来,是为了让本包原有的 `test/*.mjs` 不必改 import 就继续可用。
10
+ *
11
+ * @module dsh-session-ops/sessionlog
12
+ */
13
+ export { CHECKSUM_OPTIONS, ZSTD_MAGIC, hasZstd, readHeaderFromBuffer, rewriteHeaderCwd, scanZstdFrames, } from './store/frames.js';
14
+ export { compressionSuffix, encodeSegment, generationLogFilename, logPath, projectKey, sessionDir, } from './store/paths.js';