@morlay/dsh-session-mode 0.0.1 → 0.0.3

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/src/index.ts CHANGED
@@ -1,26 +1,6 @@
1
- /**
2
- * 会话模式的 host 半:模式清单(config 里的纯数据)、会话 ↔ 模式的选择、按会话应用 persona 与收口。
3
- *
4
- * **模式不是 Cordis 子树**。旧的 `@morlay/dsh-agent-preset` 每个模式都是一行
5
- * `@deepseek-ai/dsh-agent-preset`,`config.plugins` 里装 persona 与 scope 行,靠 preset scope 的父链
6
- * 对会话生效——代价是整个官方 registry(声明式行、每 revision 一棵 Loader 子树、`isolate` realm)都在
7
- * 部署里。这里只留两件事:模式是一份数据,应用落在会话自己的 scope 上:
8
- *
9
- * | 事实 | 落在哪 |
10
- * | ---------------- | ------------------------------------------------------------------------------------------ |
11
- * | 模式清单与默认值 | 本行的 `config`(装配层可整体改写;`modes.ts` 给形状与校验) |
12
- * | 各模式的默认模型 | 同一份 config 的顶层 `models`(**volatile**:设置页那张卡片改的就是它) |
13
- * | 会话当前模式 | session 事件 `session-mode/selected` + 投影 `sessionMode`(log-only,重建读投影) |
14
- * | 提示词 | `persona`:把模式的 persona 注册到该 agent 的 scope(`persona.ts`) |
15
- * | 工具与注入开关 | 推给 `ctx.sessionToolScope`(`@morlay/dsh-context-assembler/scope`,行 id `context-assembler-scope`) |
16
- * | 页面上的选择面 | HTTP 路由 `GET/POST /session-mode`(清单与切换)+ 会话投影(当前值) |
17
- *
18
- * 应用时机是 `agent/created`:它早于任何一次提示词装配(装配发生在 turn 里),所以 persona 一定在该会话
19
- * 第一次装配之前就注册好了。模式在**空白会话**里可以切换,切换时对着已有的 agent 重新应用一遍。
20
- *
21
- * 切换只允许在空白窗口(还没开过 turn):会话的历史是在某个模式的工具集与提示词下产生的,换了模式,那段
22
- * 历史就与实际装配对不上——与上游 `agentPresets.select` 的判据一致,也用同一个投影(`turnBoundary`)。
23
- */
1
+ // 会话模式的 host 半:模式清单(config 里的纯数据)、会话 ↔ 模式的选择、按会话应用 persona 与**收口**。模式是一份
2
+ // 数据,应用落在会话自己的 scope 上(persona + 工具收口与三个注入开关,见 `./scope.ts`),默认模型住在
3
+ // `modes.<id>.defaultModel`;换模式只允许在空白窗口。取舍见 `.agents/designs/20260924-会话模式.md`。
24
4
 
25
5
  import { Service, type Context } from "@deepseek-ai/cordis";
26
6
  import type { Agent } from "@deepseek-ai/dsh-agent";
@@ -35,32 +15,31 @@ import type {} from "@deepseek-ai/dsh-session-projection";
35
15
  // Type-only:官方 roster 的会话投影(`agentPreset`)与它的选择事件——选择面归官方 preset,
36
16
  // 我们读它的选择、落成自己的会话事实。
37
17
  import type {} from "@deepseek-ai/dsh-agent-preset-registry";
38
- import type { SessionToolScope } from "@morlay/dsh-context-assembler/scope";
18
+ // Type-only:上游 fs 的 policy 事件词汇(`fs/write-intent` / `fs/edit-intent` 的签名与 `FsWriteIntent`)。
19
+ import type {} from "@deepseek-ai/dsh-fs";
39
20
  import { z } from "zod";
40
21
  import {
41
22
  configProblem,
23
+ derivedSkills,
42
24
  type ResolvedConfig,
43
25
  type SessionMode,
44
26
  type SessionModeRole,
45
27
  } from "./modes.ts";
46
28
  import { installPersona } from "./persona.ts";
29
+ import { SessionScope } from "./scope.ts";
47
30
  import {
48
31
  SESSION_MODE_PATH,
32
+ type PolicyName,
49
33
  type SessionModeRoster,
50
34
  type SessionModeRow,
51
35
  type SessionModeSelectResult,
52
36
  } from "./shared.ts";
53
37
 
54
- /** Cordis 插件名:与行 id 一致。 */
38
+ // Cordis 插件名:与行 id 一致。
55
39
  export const name = "session-mode";
56
40
 
57
- /**
58
- * 依赖:投影服务(登记 `sessionMode`、读 `turnBoundary`)、读会话与 agent 的两个注册表,以及提示词注册表
59
- * (persona 的 section 注册在它上面)。
60
- *
61
- * 这些名字必须在 `inject` 里点名:cordis 的**属性访问**(`this.ctx.sessions`)要求本 fiber 声明过那个服务,
62
- * 未声明会抛 `cannot get property "sessions" without inject`(`ctx.get(name)` 才不需要声明)。
63
- */
41
+ // 这些名字必须在 `inject` 里点名:cordis 的属性访问(`this.ctx.sessions`)要求本 fiber 声明过那个服务,
42
+ // 未声明会抛 `cannot get property "sessions" without inject`(`ctx.get(name)` 才不需要声明)。
64
43
  export const inject = ["agents", "sessions", "sessionProjections", "systemPrompt"];
65
44
 
66
45
  export { Config } from "./modes.ts";
@@ -72,7 +51,8 @@ export type {
72
51
  SessionModeRole,
73
52
  } from "./modes.ts";
74
53
  export { SESSION_MODE_PATH } from "./shared.ts";
75
- export type { SessionModeRoster, SessionModeRow } from "./shared.ts";
54
+ export { POLICY_NAMES } from "./shared.ts";
55
+ export type { PolicyName, SessionModeRoster, SessionModeRow } from "./shared.ts";
76
56
 
77
57
  declare module "@deepseek-ai/cordis" {
78
58
  interface Context {
@@ -82,10 +62,7 @@ declare module "@deepseek-ai/cordis" {
82
62
 
83
63
  declare module "@deepseek-ai/dsh-session/types" {
84
64
  interface SessionEventMap {
85
- /**
86
- * 会话在空白窗口里换了模式。**log-only**:它记录此后每一步实际运行的模式,恢复与 fork 时据此重建
87
- * (模式决定模型看到的工具与提示词,所以它必须进日志)。
88
- */
65
+ // 会话在空白窗口里换了模式;**log-only**,恢复与 fork 据此重建(模式决定模型看到的工具与提示词)。
89
66
  "session-mode/selected": { sessionMode: string };
90
67
  }
91
68
  }
@@ -96,16 +73,16 @@ declare module "@deepseek-ai/dsh-session-projection/types" {
96
73
  sessionModeEditable: boolean;
97
74
  }
98
75
  interface SessionProjectionMap {
99
- /** 会话当前模式;`null` 表示没选过(用部署默认)。 */
76
+ // 会话当前模式;`null` 表示没选过(用部署默认)。
100
77
  sessionMode: string | null;
101
- /** 会话还能不能换模式:`true` 是选择器,`false` 是只读标签(client 那个 chip 据此变形)。 */
78
+ // 会话还能不能换模式:`true` 是选择器,`false` 是只读标签(client 那个 chip 据此变形)。
102
79
  sessionModeEditable: boolean;
103
80
  }
104
81
  }
105
82
 
106
83
  const sessionModeSchema: z.ZodType<string | null> = z.union([z.string(), z.null()]);
107
84
 
108
- /** 会话模式的投影:初值来自空日志(没选过就是 `null`),只被选择事件推进。 */
85
+ // 会话模式的投影:初值来自空日志(没选过就是 `null`),只被选择事件推进。
109
86
  export const sessionModeProjection = {
110
87
  key: "sessionMode",
111
88
  stateSchema: sessionModeSchema,
@@ -118,13 +95,8 @@ export const sessionModeProjection = {
118
95
 
119
96
  const sessionModeEditableSchema: z.ZodType<boolean> = z.boolean();
120
97
 
121
- /**
122
- * 这个会话能不能换模式的投影:空白会话为 `true`,一旦 `turn/start` 落库就永远 `false`(换模式要的是**整段
123
- * 历史**的模式一致,所以"开过 turn"之后连正在跑的那个 turn 也算)。
124
- *
125
- * **判据只有这一处**:服务端 {@link SessionModes.select} 的拒绝与 client chip 的只读形态都读它——以前
126
- * client 只能等服务端报错,现在连入口都不给,而两边的结论来自同一份会话事实。
127
- */
98
+ // 这个会话能不能换模式的投影:空白会话为 `true`,`turn/start` 一落库就永远 `false`(换模式要的是整段历史的
99
+ // 模式一致)。判据只有这一处——服务端拒绝与 client chip 的只读形态都读它。
128
100
  export const sessionModeEditableProjection = {
129
101
  key: "sessionModeEditable",
130
102
  stateSchema: sessionModeEditableSchema,
@@ -134,12 +106,16 @@ export const sessionModeEditableProjection = {
134
106
  stateVersion: 1,
135
107
  } satisfies ProjectionDefinition<"sessionModeEditable", boolean>;
136
108
 
137
- /** 模式清单、默认模式、按会话读取与切换。 */
109
+ // 模式清单、默认模式、按会话读取与切换。
138
110
  export class SessionModes extends Service {
139
- /** 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。 */
111
+ // 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。
140
112
  private readonly installs = new WeakMap<Agent, { mode: string; dispose: () => void }>();
141
113
 
142
- /** 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。 */
114
+ // 按会话收口:工具名单、instruction / 技能目录 / 动态快照三个开关。它由本行**内部持有**(不发布服务)——收口的
115
+ // 输入就是模式定义、唯一消费者也是模式。
116
+ private readonly scope: SessionScope;
117
+
118
+ // 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。
143
119
  readonly config: {
144
120
  default: string;
145
121
  modes: Record<string, SessionMode>;
@@ -156,16 +132,38 @@ export class SessionModes extends Service {
156
132
  // 退役的顶层 `models` 还配着值就让装配期报错。
157
133
  const problem = configProblem({ ...this.config, models: config.models });
158
134
  if (problem !== undefined) throw new Error(problem);
135
+ this.scope = new SessionScope(ctx);
159
136
  ctx.sessionProjections.register(sessionModeProjection);
160
137
  ctx.sessionProjections.register(sessionModeEditableProjection);
138
+ // 按模式的 policy 拦截:两条上游 waterfall 各 `prepend` **一次**,注册在行 ctx 上、随行卸载一起撤。
139
+ //
140
+ // 接缝为什么只有这一种:fs 的调用是 `ctx.waterfall('fs/write-intent', target, actor, next)`——事件名在第一个
141
+ // 参数上,而 cordis 只在"第一个参数是对象 / 函数"时才取接收者并按 scope 过滤,所以这两条 waterfall 上
142
+ // **没有 scope 过滤**:注册在该 agent 的 ctx 上买不到隔离,只会多 N 份判断。归属只能从 `actor.agent` 认
143
+ // (工具把自己的 exec 当 actor 传进来),模式的判据每次调用**现算**——于是切模式 / `applyTo` / 子代理继承
144
+ // 都不需要换监听器,"重复应用不重复注册"是结构上的事,不靠判等维持。
145
+ //
146
+ // 位置:上游 `fs-observation-policy` 在这两条 waterfall 上**独占决策槽**(它不调 `next()`),所以链首只能靠
147
+ // `prepend` 抢——站在它后面就没有决策权。这份实现的前提就是这条契约(见
148
+ // `.agents/designs/20260929-按模式的policy拦截.md`)。
149
+ ctx.on(
150
+ // `satisfies PolicyName`:注册的事件名与 `POLICY_NAMES`(装配期校验与页面候选键读的同一份名单)必须在类型面
151
+ // 对得上——名单里删掉一个名字,这里就编不过,而不是静默拦不住。
152
+ "fs/write-intent" satisfies PolicyName,
153
+ (_target, actor, next) => this.decidePolicy("fs/write-intent", actor, next),
154
+ { prepend: true },
155
+ );
156
+ ctx.on(
157
+ "fs/edit-intent" satisfies PolicyName,
158
+ (_target, actor, next) => this.decidePolicy("fs/edit-intent", actor, next),
159
+ { prepend: true },
160
+ );
161
161
  // 会话一建立就装上:这早于它的第一次装配,persona 因此一定在装配之前注册好。
162
162
  ctx.on("agent/created", ({ agent }) => {
163
163
  this.installFor(agent);
164
164
  });
165
- // 官方 roster 在空白窗口换 preset 时,把该会话的扩展换成新 preset 那一份:先把它写成我们的会话事实
166
- // (`installFor` 读的就是这份事实),再按新模式装一遍。
167
- // 只在 preset → 模式的映射唯一时动手:本部署两个模式共享同一份 preset,反查无意义(`modeForPreset`
168
- // 返回 `undefined`),选模式不会经过这条监听,模式事实由 `select` 自己落。
165
+ // 官方 roster 换 preset 时把该会话的扩展换成新 preset 那一份(先落成会话事实再重装);
166
+ // 只在 preset → 模式的映射唯一时动手(共享同一份 preset 时反查无意义)。
169
167
  ctx.on("agent-preset/selected", (sessionId: SessionId, preset: string) => {
170
168
  const mapped = this.modeForPreset(preset);
171
169
  const agent = ctx.agents.get(sessionId);
@@ -177,12 +175,12 @@ export class SessionModes extends Service {
177
175
  });
178
176
  }
179
177
 
180
- /** 新会话用它:config 里的 `default`。 */
178
+ // 新会话用它:config 里的 `default`。
181
179
  get defaultId(): string {
182
180
  return this.config.default;
183
181
  }
184
182
 
185
- /** 选择器要的清单:只列 `main` 角色的模式(id、展示名、说明,顺序即 config 里 `modes` 的插入序)。 */
183
+ // 选择器要的清单:只列 `main` 角色的模式(id、展示名、说明,顺序即 config 里 `modes` 的插入序)。
186
184
  list(): SessionModeRow[] {
187
185
  return this.idsFor("main").map((id) => {
188
186
  const mode = this.definition(id);
@@ -194,32 +192,24 @@ export class SessionModes extends Service {
194
192
  });
195
193
  }
196
194
 
197
- /**
198
- * 声明了某个角色的模式 id(顺序即 config 的插入序):`main` 给用户选择器,`subagent` 给子代理候选。
199
- * @param role - 目标角色。
200
- * @returns 该角色下的模式 id。
201
- */
195
+ // 声明了某个角色的模式 id(顺序即 config 的插入序):`main` 给用户选择器,`subagent` 给子代理候选。
202
196
  idsFor(role: SessionModeRole): string[] {
203
197
  return Object.entries(this.config.modes)
204
198
  .filter(([, mode]) => mode.role.includes(role))
205
199
  .map(([id]) => id);
206
200
  }
207
201
 
208
- /**
209
- * 同 {@link idsFor},给的是定义——"指定 mode" 那条接缝要拿候选集。
210
- * @param role - 目标角色。
211
- * @returns 该角色下的模式与其定义。
212
- */
202
+ // 同 `idsFor`,给的是定义——"指定 mode" 那条接缝要拿候选集。
213
203
  modesFor(role: SessionModeRole): { id: string; mode: SessionMode }[] {
214
204
  return this.idsFor(role).map((id) => ({ id, mode: this.definition(id) }));
215
205
  }
216
206
 
217
- /** 页面用的清单 + 默认模式。 */
207
+ // 页面用的清单 + 默认模式。
218
208
  roster(): SessionModeRoster {
219
209
  return { default: this.defaultId, modes: this.list() };
220
210
  }
221
211
 
222
- /** 按 id 取定义;未知 id 直接抛(切换路径上它就是用户的错)。 */
212
+ // 按 id 取定义;未知 id 直接抛(切换路径上它就是用户的错)。
223
213
  definition(id?: string): SessionMode {
224
214
  const wanted = id ?? this.defaultId;
225
215
  const mode = this.config.modes[wanted];
@@ -231,30 +221,50 @@ export class SessionModes extends Service {
231
221
  return mode;
232
222
  }
233
223
 
234
- /** 会话当前模式:投影上有就用它,否则是部署默认。 */
224
+ // 会话当前模式:投影上有就用它,否则是部署默认。
235
225
  modeOf(session: Session): string {
236
226
  const selected = this.ctx.sessionProjections.stateOf(session, "sessionMode");
237
227
  return selected ?? this.defaultId;
238
228
  }
239
229
 
240
- /** 会话当前模式的定义。 */
230
+ // 会话当前模式的定义。
241
231
  modeOfSession(session: Session): SessionMode {
242
232
  return this.definition(this.modeOf(session));
243
233
  }
244
234
 
245
- /**
246
- * 把某个空白会话切到某个模式。
247
- *
248
- * 模式带着它的 preset(`preset` 决定行清单),所以这里**先把 agent preset 换成模式声明的那个**,再落我们的
249
- * 会话事实:否则 preset realm 还是旧那一套的行,而它的注入在我们的开关之外(旧形态 `chat` 挂 `minimal`、
250
- * 会话的 preset 却还是 `standard` 时,上游 `agent-instructions` 会照旧把工作区指令注进这个"不要注入"的会话)。
251
- *
252
- * 目标 preset 与当前挂着的**相同时不切**(本部署两个模式共享同一份 preset,所以切模式通常走不到这一步):
253
- * 换 preset 是一次重挂(卸旧行、装新行),没有变化就没有理由付出这个代价。
254
- * @param sessionId - 目标会话(必须还没有开过 turn)。
255
- * @param mode - 目标模式 id。
256
- * @returns 提交后的模式 id。
257
- */
235
+ // 这条 policy 规则在**这次调用**里还生效吗(`true` = 交给上游,`false` = 被这个模式禁用)。
236
+ // 判据只有一处:`actor.agent` → `agent.session` → `modeOf`。认不出 agent(没有 agent 的直接调用)、或模式 id 在
237
+ // config 里找不到(重挂前后的瞬间)时一律按"没配"读——拦截路径绝不抛错。
238
+ private policyInForce(policy: PolicyName, actor: object | undefined): boolean {
239
+ const agent = (actor as { readonly agent?: Agent } | undefined)?.agent;
240
+ if (agent === undefined) return true;
241
+ const mode = this.config.modes[this.modeOf(agent.session)];
242
+ if (mode === undefined) return true;
243
+ // 合成规则(deny 优先):生效集合 =(`allowPolicies` 空 ? 全部 : `allowPolicies`)− `denyPolicies`。
244
+ if (mode.denyPolicies.includes(policy)) return false;
245
+ return mode.allowPolicies.length === 0 || mode.allowPolicies.includes(policy);
246
+ }
247
+
248
+ // 一条 policy 规则的裁决:规则生效就原样交给上游(`next()` 的返回值或拒绝照旧出去);被禁用就**先让上游算完
249
+ // 再丢掉结论**——上游在链首之后独占决策槽,"放过"唯一可能的形态就是无条件裁决(`fs/edit-intent` 上它是免
250
+ // "先读后改",`fs/write-intent` 上是连陈旧版本 / CAS 那层安全网一起丢)。上游抛出的拒绝也属于这条裁决:
251
+ // 接住它,返回 `undefined`(= 这次调用按"没有这条规则"继续)。
252
+ private async decidePolicy<T>(
253
+ policy: PolicyName,
254
+ actor: object | undefined,
255
+ next: () => T | Promise<T>,
256
+ ): Promise<T | undefined> {
257
+ if (this.policyInForce(policy, actor)) return await next();
258
+ try {
259
+ await next();
260
+ } catch {
261
+ // 上游的拒绝是这次裁决的一部分:禁用就是连它一起不要。
262
+ }
263
+ return undefined;
264
+ }
265
+
266
+ // 把某个空白会话(必须还没开过 turn)切到某个模式:先把 agent preset 换成模式声明的那个
267
+ // (目标与当前相同时不切——换 preset 是一次重挂),再落会话事实并重装该 agent 的扩展。
258
268
  async select(sessionId: SessionId, mode: string): Promise<string> {
259
269
  const definition = this.definition(mode);
260
270
  if (!definition.role.includes("main")) {
@@ -262,16 +272,15 @@ export class SessionModes extends Service {
262
272
  }
263
273
  const session = this.ctx.sessions.get(sessionId);
264
274
  if (session === undefined) throw new Error(`未知的会话 ${sessionId}`);
265
- // 空白窗口的判据只有一处:{@link sessionModeEditableProjection}。client 那个 chip 读的是同一个投影,
266
- // 所以"看起来能选"与"服务端接受"不会脱节。
275
+ // 空白窗口的判据只有一处:`sessionModeEditableProjection`(client chip 读同一个投影)。
267
276
  if (this.ctx.sessionProjections.stateOf(session, "sessionModeEditable") === false) {
268
277
  throw new Error("这个会话已经开始,模式不能再改;要换模式请新开一个会话。");
269
278
  }
270
279
  const agent = this.ctx.agents.get(sessionId);
271
280
  const registry = this.presetRegistry();
272
281
  if (agent !== undefined && registry !== undefined && definition.preset.length > 0) {
273
- // 官方那条路自己也会查空白窗口(`agent-preset/locked`)。它切完会 emit `agent-preset/selected`,
274
- // 下面那个监听据此把模式落成会话事实并装一遍——所以写事实前先比一次投影,同一个值不写第二条。
282
+ // 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
283
+ // 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
275
284
  if (this.presetOfAgent(registry, agent) !== definition.preset) {
276
285
  await registry.select(agent, definition.preset);
277
286
  }
@@ -284,12 +293,7 @@ export class SessionModes extends Service {
284
293
  return mode;
285
294
  }
286
295
 
287
- /**
288
- * 某个 agent 当前挂着的 preset(registry 的 `composedPreset`)。
289
- *
290
- * 读不到时返回 `undefined`(按"未知"处理 → 该切就切):`composedPreset` 是 registry 较新的读面,替身与老
291
- * 版本可能没有它;而"没挂任何 preset"(返回值 `undefined`)与"读不到"在这里是同一个结论。
292
- */
296
+ // 某个 agent 当前挂着的 preset(registry 的 `composedPreset`);读不到时按"未知"处理(该切就切)。
293
297
  private presetOfAgent(registry: Context["agentPresets"], agent: Agent): string | undefined {
294
298
  const read = (registry as { composedPreset?: (ctx: Context) => string | undefined })
295
299
  .composedPreset;
@@ -301,10 +305,8 @@ export class SessionModes extends Service {
301
305
  }
302
306
  }
303
307
 
304
- /**
305
- * 官方 preset registry:行清单与它的选择面住在那一行。不在 `inject` 里点名(headless 部署没有它,
306
- * 点名会让本行永不激活),所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
307
- */
308
+ // 官方 preset registry;不在 `inject` 里点名(headless 部署没有它,点名会让本行永不激活),
309
+ // 所以按"可能拿不到"读——`ctx.get` 在当前 ctx 没声明那个服务时会抛。
308
310
  private presetRegistry(): Context["agentPresets"] | undefined {
309
311
  try {
310
312
  return this.ctx.get("agentPresets");
@@ -313,13 +315,8 @@ export class SessionModes extends Service {
313
315
  }
314
316
  }
315
317
 
316
- /**
317
- * 把某个活着的 agent 切到某个模式。与 {@link select} 的差别:它不要求空白会话——子代理创建时的继承
318
- * 走这里,**未来的"指定 mode"入口(模型侧或配置侧)也走这里**(那条接缝还没做)。
319
- * @param agent - 目标 agent。
320
- * @param mode - 目标模式 id。
321
- * @param options.record - 是否把这次切换写进会话日志(缺省写;只想改当前进程时给 `false`)。
322
- */
318
+ // 把某个活着的 agent 切到某个模式(不要求空白会话:子代理创建时的继承走这里);
319
+ // `options.record: false` 时不写会话日志(只改当前进程)。
323
320
  applyTo(agent: Agent, mode: string, options: { record?: boolean } = {}): void {
324
321
  this.definition(mode);
325
322
  if (options.record !== false) {
@@ -328,17 +325,14 @@ export class SessionModes extends Service {
328
325
  this.installFor(agent, mode);
329
326
  }
330
327
 
331
- /**
332
- * 该 agent 用哪个模式:会话选过(投影上有)优先,子代理继承父,其余用部署默认。
333
- *
334
- * 继承要**写进子会话日志**:它是一条会话事实,冷恢复与 fork 都要靠它重建({@link modeOf} 只读投影)。
335
- */
328
+ // 该 agent 用哪个模式:会话选过(投影上有)优先,子代理继承父,其余用部署默认。
329
+ // 继承要写进子会话日志——它是一条会话事实,冷恢复与 fork 靠它重建(`modeOf` 只读投影)。
336
330
  private resolveModeId(agent: Agent): string {
337
331
  // `undefined` = 这个投影没注册(或还没初始化),与"没选过"(`null`)一样落到下一层。
338
332
  const selected = this.ctx.sessionProjections.stateOf(agent.session, "sessionMode");
339
333
  if (typeof selected === "string") return selected;
340
- // 会话级选择归官方 roster:它的 preset 决定这个会话用哪份扩展,我们把结论落成自己的会话事实
341
- // (`sessionMode` 是会话级事实的 home:恢复、子代理继承、服务端读取都读它)。
334
+ // 会话级选择归官方 roster;结论落成我们自己的会话事实(`sessionMode`:恢复、子代理继承、
335
+ // 服务端读取都读它)。
342
336
  const mapped = this.modeForPreset(this.presetOf(agent.session));
343
337
  if (mapped !== undefined) {
344
338
  agent.session.append("session-mode/selected", { sessionMode: mapped });
@@ -350,7 +344,7 @@ export class SessionModes extends Service {
350
344
  return inherited;
351
345
  }
352
346
 
353
- /** 官方 roster 选定的 preset(没选过、或 registry 没装时为 `undefined`)。 */
347
+ // 官方 roster 选定的 preset(没选过、或 registry 没装时为 `undefined`)。
354
348
  private presetOf(session: Session): string | undefined {
355
349
  try {
356
350
  const state = this.ctx.sessionProjections.stateOf(session, "agentPreset");
@@ -361,16 +355,8 @@ export class SessionModes extends Service {
361
355
  }
362
356
  }
363
357
 
364
- /**
365
- * 某个 agent preset 对应的模式 id——**只在映射唯一时**回答(几个模式挂同一份 preset 时返回
366
- * `undefined`,不反查)。
367
- *
368
- * 本部署的两个模式共享同一个 preset(`MODE_PRESET_ID`),差异全在会话级收口,所以 preset → 模式的反查在
369
- * 这里无意义:模式由**会话事实**决定(`session-mode/selected` 投影 → 子代理继承 → 部署默认),
370
- * 官方 roster 选了什么 preset 不改变这个会话是哪个模式。这条反查留给"一对一映射"的部署形态。
371
- * @param preset - preset id(`undefined` 表示没选过、或 registry 没装)。
372
- * @returns 该 preset 唯一对应的模式 id;没配扩展、或由多个模式共享时 `undefined`。
373
- */
358
+ // 某个 preset 对应的模式 id——只在映射唯一时回答(共享同一份 preset、或没有模式挂它时 `undefined`);
359
+ // 本部署的模式由会话事实决定,这条反查留给"一对一映射"的部署形态。
374
360
  modeForPreset(preset: string | undefined): string | undefined {
375
361
  if (preset === undefined) return undefined;
376
362
  const owners = Object.keys(this.config.modes).filter(
@@ -379,7 +365,7 @@ export class SessionModes extends Service {
379
365
  return owners.length === 1 ? owners[0] : undefined;
380
366
  }
381
367
 
382
- /** 子代理(有 durable 父会话)继承父当前模式;父不在场、或不是子代理时没有可继承的。 */
368
+ // 子代理(有 durable 父会话)继承父当前模式;父不在场、或不是子代理时没有可继承的。
383
369
  private inheritedModeId(agent: Agent): string | undefined {
384
370
  const parentId = agent.session.header.parentSession;
385
371
  if (parentId === undefined) return undefined;
@@ -387,7 +373,7 @@ export class SessionModes extends Service {
387
373
  return parent === undefined ? undefined : this.modeOf(parent.session);
388
374
  }
389
375
 
390
- /** 装或换该 agent 的那一份(幂等:同一模式不重复注册)。 */
376
+ // 装或换该 agent 的那一份(幂等:同一模式不重复注册)。
391
377
  private installFor(agent: Agent, modeId: string = this.resolveModeId(agent)): void {
392
378
  const installed = this.installs.get(agent);
393
379
  if (installed?.mode === modeId) return;
@@ -403,16 +389,20 @@ export class SessionModes extends Service {
403
389
  for (const dispose of disposers) dispose();
404
390
  },
405
391
  });
406
- this.toolScope()?.apply(agent, mode);
392
+ // 收口要的那几项按模式定义整份推过去:`name` / `allowTools` / `denyTools` / 三个开关。`skills` 缺省时在这里
393
+ // 按这份定义自己的工具名单推导(见 `derivedSkills`)——收口那一侧只认解析后的布尔。
394
+ this.scope.apply(agent, {
395
+ name: mode.name,
396
+ allowTools: mode.allowTools,
397
+ denyTools: mode.denyTools,
398
+ instructions: mode.instructions,
399
+ skills: mode.skills ?? derivedSkills(mode),
400
+ runtimeContext: mode.runtimeContext,
401
+ });
407
402
  }
408
403
 
409
- /**
410
- * 模式的默认模型(`modes.<模式 id>.defaultModel`)兜底:只在会话**尚无任何模型事实**(没选过模型、也
411
- * 还没跑过请求)时接管这一请求的路由;一旦用户选过(投影 `pending`)或会话已经落过 header,就不再插手。
412
- *
413
- * 它是**配置事实**,不写会话事件——重启后仍由 config 决定;设置页里那条会话级选择才是会话事实。读的是构造
414
- * 时那份模式清单快照:设置页保存会让这一行重挂(`reconcileProfilePatches`),新定义随重挂生效。
415
- */
404
+ // 模式的默认模型兜底:只在会话尚无任何模型事实(没选过模型、也没落过 request header)时接管这一请求的
405
+ // 路由。它是配置事实、不写会话事件,读构造时的模式快照(设置页保存会让这一行重挂)。
416
406
  private installDefaultModel(agent: Agent, modeId: string): () => void {
417
407
  return agent.ctx.on("agent/request", async (_payload, next): Promise<LlmCallConfig> => {
418
408
  const resolved = await next();
@@ -432,15 +422,6 @@ export class SessionModes extends Service {
432
422
  };
433
423
  });
434
424
  }
435
-
436
- /** 收口服务由 `@morlay/dsh-context-assembler/scope` 那一行发布;没装它就只有 persona。 */
437
- private toolScope(): SessionToolScope | undefined {
438
- try {
439
- return this.ctx.get("sessionToolScope");
440
- } catch {
441
- return undefined;
442
- }
443
- }
444
425
  }
445
426
 
446
427
  export function apply(ctx: Context, config: ResolvedConfig): void {
@@ -528,12 +509,8 @@ async function handleRoute(
528
509
  }
529
510
  }
530
511
 
531
- /**
532
- * 把清单与切换挂到同一张宿主路由表上。
533
- *
534
- * `webServer` **必须等**:它可能比本行晚激活,而一次性 `ctx.get` 取到 `undefined` 之后不会再试一次——
535
- * 路由没注册的后果是请求落到静态资源 fallback,非 GET/HEAD 一律 405。
536
- */
512
+ // 把清单与切换挂到宿主路由表上。`webServer` 必须等:它可能比本行晚激活,一次性 `ctx.get` 取不到就不会
513
+ // 再试(后果是请求落到静态资源 fallback)。
537
514
  function registerHttpRoutes(ctx: Context, modes: SessionModes): void {
538
515
  ctx.inject(["webServer"], (scope) => {
539
516
  // webServer 的类型由上游 `@deepseek-ai/dsh-host-webserver` 声明;这里只按用到的 register 面做结构转换。
@@ -1,59 +1,46 @@
1
- /**
2
- * 模式定义的真源:`session-mode` 行的 `config` 由 `./rows.ts` 渲染(装配入口在 `packages/bundles/session-mode-profile`)。
3
- *
4
- * "自定义"就落在这份数据上——装配层(profile 的用户 patch 层)可以整体改写 `config.modes`,也可以只给
5
- * 某个模式换提示词或工具白名单,不需要任何插件行。
6
- *
7
- * `allowTools` 从 [`@morlay/dsh-agent-toolkit/rows`](../../../dsh-agent-toolkit/src/rows.ts) 的
8
- * `TOOLKIT_TOOL_NAMES` 派生(工具名与汉化同源):**行清单由本部署自己注册的那份 preset 提供**
9
- * ({@link MODE_PRESET_ID},两个模式共享它),白名单里那份 preset 没有的工具自动跳过。想改某一个模式的名单,
10
- * 直接在这条源数据里加/减。
11
- */
1
+ // 模式定义的真源:`session-mode` 行的 `config` 由 `./rows.ts` 渲染(装配入口在 `packages/bundles/session-mode-profile`)。
2
+ // **行清单不由本包持有**:谁挂在这份定义上的会话用什么工具,取决于它挂着的 agent preset(官方 shipped preset),
3
+ // `allowTools` 只是在这之上收窄——留空就是不收窄。改某个模式的名单就改这条源数据。
12
4
 
13
- import { TOOLKIT_TOOL_NAMES } from "@morlay/dsh-agent-toolkit/rows";
5
+ import type { PolicyName } from "./shared.ts";
14
6
 
15
- /**
16
- * 本部署自己注册的那份 agent preset 的 id:两个模式共享它(行清单见
17
- * `packages/bundles/session-mode-profile`,`config.plugins` 引用 `@morlay/dsh-agent-toolkit/rows` 的
18
- * `TOOLKIT_PRESET_ROWS`)。
19
- *
20
- * 为什么自己注册而不是复用官方 preset:官方 `minimal` 没有 `tool-web`,`chat` 的白名单(提问 + 联网三件)
21
- * 收口后一件都不剩;而且官方 preset 自带的上游注入要在我们的开关之外做"让位",抢 `skill` 面还得把目录 kind
22
- * 换掉。行清单归我们之后,两个模式的**差异全在会话级收口**(persona / `allowTools` / 两个开关)。
23
- */
24
- export const MODE_PRESET_ID = "mode-switch";
25
-
26
- /** 一个模式的源定义:就是 `session-mode` 行 `config.modes` 里的一项。 */
7
+ // 一个模式的源定义:就是 `session-mode` 行 `config.modes` 里的一项。
27
8
  export interface ModeSource {
28
9
  readonly id: string;
29
- /**
30
- * 挂哪个 agent preset(它的 `id`):行清单由那个 preset 提供,这里只写扩展。
31
- *
32
- * 官方四个 shipped preset 照旧可选(官方 roster 选择面没动),但我们的模式都挂
33
- * {@link MODE_PRESET_ID};两个模式共享同一个 preset 是**有意**的(差异靠会话级收口表达),所以
34
- * preset → 模式的反查在这种共享下无意义(见 `SessionModes.modeForPreset`)。
35
- */
36
- readonly preset: string;
10
+ // 挂哪个 agent preset(它的 `id`):**可选**,不写就是不绑——选这个模式不换 preset,会话保持它当前挂着的那份,
11
+ // 行清单由那份 preset 提供(官方四个 shipped preset 照旧可选)。写了才在切模式时把 preset 切过去;
12
+ // 模式定义里可以整体改写它(用户 patch 层)。
13
+ readonly preset?: string;
37
14
  readonly name: string;
38
15
  readonly description: string;
39
- /** 归谁用:`main`(用户选择器,缺省)、`subagent`(可作子代理 mode 的候选)。 */
16
+ // 归谁用:`main`(用户选择器,缺省)、`subagent`(可作子代理 mode 的候选)。
40
17
  readonly role?: readonly ("main" | "subagent")[];
41
18
  readonly persona?: { readonly prefix?: string; readonly suffix?: string };
42
- readonly allowTools: readonly string[];
19
+ // 这个模式能用的工具;**可选**,不写(留空)就是不设收窄——用会话挂着的 preset 的全部工具。
20
+ readonly allowTools?: readonly string[];
21
+ // 这个模式不用的工具(黑名单):从 `allowTools` 定的那份里减掉(deny 优先)。
22
+ readonly denyTools?: readonly string[];
23
+ // 上游 policy 规则的生效白名单:**可选**,不写(留空)= 全部规则生效。
24
+ readonly allowPolicies?: readonly PolicyName[];
25
+ // 上游 policy 规则的黑名单:列出的规则禁用(它在上游那条 waterfall 上的裁决被绕过)。
26
+ readonly denyPolicies?: readonly PolicyName[];
43
27
  readonly instructions?: boolean;
28
+ // 是否要技能目录(官方 `skill-catalog` 的注入):**可选**,不写就按这个模式自己的工具名单推导(名单里含 `skill`
29
+ // 就要,见 `modes.ts` 的 `derivedSkills`)。`coding` 留空名单 = 要;`chat` 的三件里没有 `skill` = 不要。
30
+ readonly skills?: boolean;
44
31
  readonly runtimeContext?: boolean;
45
- /** 这个模式的默认模型(省略就跟全局 `agent-default-model`)。 */
32
+ // 这个模式的默认模型(省略就跟全局 `agent-default-model`)。
46
33
  readonly defaultModel?: ModeModelSource;
47
34
  }
48
35
 
49
- /** 一个模式的默认模型源定义:`modes.<模式 id>.defaultModel`。 */
36
+ // 一个模式的默认模型源定义:`modes.<模式 id>.defaultModel`。
50
37
  export interface ModeModelSource {
51
38
  readonly provider: string;
52
39
  readonly model: string;
53
40
  readonly reasoningEffort?: string;
54
41
  }
55
42
 
56
- /** 编码模式:编程专家 + 语言与思考纪律 + 工作目录提醒。 */
43
+ // 编码模式:编程专家 + 语言与思考纪律 + 工作目录提醒。
57
44
  const CODING_PERSONA = {
58
45
  prefix: [
59
46
  "你是一个经验丰富的编程专家,YAGNI 是你的编程哲学,PDCA 是你的行为规范。",
@@ -62,41 +49,50 @@ const CODING_PERSONA = {
62
49
  suffix: "你的工作目录在 `{{cwd}}`",
63
50
  };
64
51
 
65
- /** 对话模式:一个助手,保留语言与思考纪律,没有 suffix。 */
52
+ // 对话模式:一个助手,保留语言与思考纪律,没有 suffix。
66
53
  const CHAT_PERSONA = {
67
54
  prefix:
68
55
  "你是一个助手。全程用中文(专有名词除外),包括但不限于思考,回答,工具描述;思考不要陷入重复循环,一旦循环立即退出。",
69
56
  };
70
57
 
71
- /** 新会话用哪个模式(`session-mode` 行的 `config.default`)。 */
58
+ // 新会话用哪个模式(`session-mode` 行的 `config.default`)。
72
59
  export const DEFAULT_MODE = "coding";
73
60
 
74
- /** 两个模式:编码与对话。**同一个 preset,差异全在会话级收口**。 */
61
+ // 两个模式:编码与对话。**都不绑定 preset**(差异全在会话级收口),行清单由会话挂着的 preset 提供:`coding`
62
+ // 不收窄(用全部),`chat` 收成提问 + 联网三件。
75
63
  export const MODE_SOURCES: readonly ModeSource[] = [
76
64
  {
77
65
  id: "coding",
78
- // 完整工具集由我们自己的 preset(`TOOLKIT_PRESET_ROWS`)提供。
79
- preset: MODE_PRESET_ID,
80
66
  name: "编码模式",
81
67
  description: "功能完整的编码 Agent:文件、Shell、检索、联网等工具常驻,其余用法说明按需加载。",
82
68
  persona: CODING_PERSONA,
83
69
  // 用户可选,也允许作为子代理的 mode(子代理默认继承父 mode,不看角色;这里是"可被指定"的候选集)。
84
70
  role: ["main", "subagent"],
85
- allowTools: [...TOOLKIT_TOOL_NAMES],
71
+ // 不写 `allowTools`:不设收窄——这个会话用它挂着的 preset 提供的全部工具(抄一份清单只会与行清单漂移)。
72
+ // 不写 `denyTools`:一件工具都不禁。
73
+ //
74
+ // `denyPolicies` 只禁 `fs/edit-intent`(上游那条"先读后改"):改文件不再要求先读过——写路径上的
75
+ // `fs/write-intent`(陈旧版本 CAS 那层安全网)照旧生效,那正是这条配置不写成"两条都禁"的理由。
76
+ denyPolicies: ["fs/edit-intent"],
86
77
  },
87
78
  {
88
79
  id: "chat",
89
- // 同一个 preset:行清单里有联网工具,收口才收得成"提问 + 联网"。
90
- preset: MODE_PRESET_ID,
80
+ // 行清单同样跟着会话:白名单里 preset 没有的工具自动跳过,所以这个模式在缺联网行的 preset 上收不出三件。
91
81
  name: "对话模式",
92
82
  description:
93
83
  "只做对话:提问与联网(搜索、抓取)三件工具,不注入系统提示词、工作区指令与技能目录。",
94
84
  persona: CHAT_PERSONA,
95
85
  // 只做用户侧对话:不做子代理的候选(父在 chat 里派发的子代理仍继承 chat,见 README 的"角色"一节)。
96
86
  role: ["main"],
97
- // 提问与联网三件:行由 preset 提供,这里只收口(preset 没有的自动跳过)。
87
+ // 提问与联网三件:行由会话挂着的 preset 提供,这里只收口(preset 没有的自动跳过)。
98
88
  allowTools: ["ask_user_question", "web_search", "web_fetch"],
89
+ // 不写任何 policy 名单:上游两条规则都照旧生效。这个模式没有文件工具,两条都碰不到——
90
+ // 配了只是噪音,所以留空。
99
91
  // 没有文件与 shell 工具,"能改工作区哪些文件、要不要走审批"对它全是噪音。
92
+ //
93
+ // 两个抑制面都关:`instructions: false` 丢掉官方 `agent-instructions`(工作区指令)的注入;这个模式不写
94
+ // `skills`,而白名单三件里没有 `skill`、`denyTools` 也留空 → 推导成 `false`,官方 `skill-catalog` 的注入
95
+ // 同样丢掉。取舍见 `.agents/designs/20260929-抑制官方注入面.md`。
100
96
  instructions: false,
101
97
  runtimeContext: false,
102
98
  },