@morlay/dsh-session-mode 0.0.15 → 0.0.16

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
@@ -9,7 +9,7 @@ import type { Agent } from "@deepseek-ai/dsh-agent";
9
9
  // 那一行可能没装(headless 部署),所以读它时按"可能为空"处理。
10
10
  import type {} from "@deepseek-ai/dsh-api-session-controller";
11
11
  import { ReasoningEffortId, type LlmCallConfig } from "@deepseek-ai/dsh-llm";
12
- import type { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
12
+ import { type Session, type SessionEvent, type SessionId } from "@deepseek-ai/dsh-session";
13
13
  import type {} from "@deepseek-ai/dsh-session";
14
14
  import { agentPresetProjectionDefinition } from "@deepseek-ai/dsh-agent-preset-registry";
15
15
  import type { ProjectionDefinition, ProjectionHandle } from "@deepseek-ai/dsh-session-projection";
@@ -18,6 +18,8 @@ import type {} from "@deepseek-ai/dsh-session-projection";
18
18
  // Type-only:上游 fs 的 policy 事件词汇(`fs/write-intent` / `fs/edit-intent` 的签名与 `FsWriteIntent`)。
19
19
  import type {} from "@deepseek-ai/dsh-fs";
20
20
  import { z } from "zod";
21
+ import { MODE_SOURCES } from "./mode-sources.ts";
22
+ import { modeConfig } from "./rows.ts";
21
23
  import {
22
24
  configProblem,
23
25
  derivedSkills,
@@ -26,14 +28,34 @@ import {
26
28
  type SessionModeRole,
27
29
  } from "./modes.ts";
28
30
  import { SessionModeCatalog } from "./catalog.ts";
31
+ import { normalizeMode } from "./modes.ts";
29
32
  import { installPersona } from "./persona.ts";
30
33
  import { SessionScope } from "./scope.ts";
31
34
  import {
35
+ asModeConfig,
36
+ resolveModeConfig,
37
+ type ModeConfig,
38
+ type ModeSession,
39
+ } from "./mode-config.ts";
40
+ import { loadModeOverrides, removeMode, writeMode } from "./mode-store.ts";
41
+ import { sessionModeDomainSpec } from "./session-mode-store.ts";
42
+ import {
43
+ POLICY_DESCRIPTIONS,
44
+ POLICY_NAMES,
45
+ toolGroupLabel,
46
+ toolGroupOf,
47
+ deniedByList,
32
48
  SESSION_MODE_PATH,
33
49
  type PolicyName,
50
+ type SessionModeActiveState,
34
51
  type SessionModeRoster,
35
52
  type SessionModeRow,
36
53
  type SessionModeSelectResult,
54
+ type SessionModeSwitchKey,
55
+ type SessionModeEditable,
56
+ type SessionModeDirectory,
57
+ type SessionModeDirectoryEntry,
58
+ type SessionModeTriState,
37
59
  } from "./shared.ts";
38
60
 
39
61
  // Cordis 插件名:与行 id 一致。
@@ -52,6 +74,18 @@ export type {
52
74
  SessionModeRole,
53
75
  } from "./modes.ts";
54
76
  export { SESSION_MODE_PATH } from "./shared.ts";
77
+
78
+ // 面板能改的那几项;`null` = 清掉这一项的会话覆盖、回到模式默认。
79
+ export type SwitchPatch = Partial<Record<SessionModeSwitchKey, boolean | null>>;
80
+ export type ModePatch = Partial<Record<keyof SessionModeEditable, unknown>>;
81
+
82
+ // TOML 不能表示 undefined:写盘前把空值字段剔掉。
83
+ function plainMode(value: Record<string, unknown>): Record<string, unknown> {
84
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
85
+ }
86
+
87
+ // 会话级**逐条目**覆盖:不碰模式的 allow/deny 名单,没配就是默认。
88
+ export type ItemKind = "tools" | "skills" | "policies";
55
89
  export { POLICY_NAMES } from "./shared.ts";
56
90
  export type { PolicyName, SessionModeRoster, SessionModeRow } from "./shared.ts";
57
91
 
@@ -62,6 +96,7 @@ declare module "@deepseek-ai/cordis" {
62
96
  }
63
97
 
64
98
  declare module "@deepseek-ai/dsh-session/types" {
99
+ // 会话级覆盖是**插件记录**:旧 harness 不认识时按 ignorable 跳过,而不是拒绝整份日志。
65
100
  interface SessionEventMap {
66
101
  // 会话在空白窗口里换了模式;**log-only**,恢复与 fork 据此重建(模式决定模型看到的工具与提示词)。
67
102
  "session-mode/selected": { sessionMode: string };
@@ -111,6 +146,17 @@ export const sessionModeEditableProjection = {
111
146
  export class SessionModes extends Service {
112
147
  // 每个 agent 已经装上的那一份(persona + 默认模型兜底;模式变了就换一份)。
113
148
  private readonly installs = new WeakMap<Agent, { mode: string; dispose: () => void }>();
149
+ // 会话级的注入开关覆盖(面板上改的那三个):只活在这个进程里;"保存为默认"把它写回模式配置。
150
+ private readonly modeOverrides = new WeakMap<Agent, ModeSession>();
151
+ // 会话覆盖的持久化表(storageDomain,与投影缓存同一层)。
152
+ private sessionsTable:
153
+ | {
154
+ get(id: SessionId): { config: Record<string, unknown> } | undefined;
155
+ put(id: SessionId, value: { config: Record<string, unknown> }): void;
156
+ delete(id: SessionId): void;
157
+ }
158
+ | undefined;
159
+ // 逐条目三态:没配就是"跟着模式的默认"。
114
160
 
115
161
  // 按会话收口:工具名单、instruction / 技能目录 / 动态快照三个开关。它由本行**内部持有**(不发布服务)——收口的
116
162
  // 输入就是模式定义、唯一消费者也是模式。
@@ -121,7 +167,10 @@ export class SessionModes extends Service {
121
167
  // 只是共享同一个 unit,不改变它的行为)。
122
168
  private projectionHandle!: ProjectionHandle<string | null>;
123
169
  private editableHandle!: ProjectionHandle<boolean>;
124
- private modelSelectionHandle!: ProjectionHandle<{ readonly pending: unknown; readonly lastUsed: unknown }>;
170
+ private modelSelectionHandle!: ProjectionHandle<{
171
+ readonly pending: unknown;
172
+ readonly lastUsed: unknown;
173
+ }>;
125
174
  private agentPresetHandle!: ProjectionHandle<string | null>;
126
175
 
127
176
  // 装配时的配置快照:`default` / `modes` 读它,改这两项靠 Loader 重挂这一行(已运行会话不自动换定义)。
@@ -136,7 +185,9 @@ export class SessionModes extends Service {
136
185
  // 快照取一份**可变**副本:volatile 的快照是深度只读,而内部按普通配置对象用(写回不走这里,改配置靠重挂)。
137
186
  this.config = {
138
187
  default: config.default.get(),
139
- modes: structuredClone(config.modes.get()) as Record<string, SessionMode>,
188
+ // 模式定义不走 cordis 配置:内置默认 + `{dshHome}/modes/*.toml`(`init` 里填)。
189
+ // 装配期注入(测试/高级用法)单存一份,`init` 时覆盖内置默认。
190
+ modes: structuredClone(config.modes ?? {}) as Record<string, SessionMode>,
140
191
  };
141
192
  // 退役的顶层 `models` 还配着值就让装配期报错。
142
193
  const problem = configProblem({ ...this.config, models: config.models });
@@ -165,6 +216,12 @@ export class SessionModes extends Service {
165
216
  (_target, actor, next) => this.decidePolicy("fs/edit-intent", actor, next),
166
217
  { prepend: true },
167
218
  );
219
+ // 删除那条也走同一个决策面:上游 `fs-observation-policy` 在这里同样独占决策槽,所以仍用 `prepend` 抢在它前面。
220
+ ctx.on(
221
+ "fs/delete-intent" satisfies PolicyName,
222
+ (_target, actor, next) => this.decidePolicy("fs/delete-intent", actor, next),
223
+ { prepend: true },
224
+ );
168
225
  // 会话一建立就装上:这早于它的第一次装配,persona 因此一定在装配之前注册好。
169
226
  ctx.on("agent/created", ({ agent }) => {
170
227
  this.installFor(agent);
@@ -184,13 +241,46 @@ export class SessionModes extends Service {
184
241
 
185
242
  // 构造即注册(`init` 是异步的):入口 `apply` 走这条异步工厂。
186
243
  static async create(ctx: Context, config: ResolvedConfig): Promise<SessionModes> {
187
- const modes = new SessionModes(ctx, config);
188
- await modes.init();
189
- return modes;
244
+ // 内置默认必须在**构造之前**就位:构造期校验要确认 `default` 落在已知的模式集合里,而行 config 现在
245
+ // 只带 `default`(模式定义走 `{dshHome}/modes/*.toml`)。装配期注入(测试 / 高级用法)给了 modes 就整份用,
246
+ // 不掺内置默认。
247
+ const injected = config.modes ?? {};
248
+ const modes =
249
+ Object.keys(injected).length === 0
250
+ ? (Object.fromEntries(
251
+ MODE_SOURCES.map((source) => [source.id, normalizeMode(modeConfig(source))]),
252
+ ) as Record<string, SessionMode>)
253
+ : injected;
254
+ const sessionModes = new SessionModes(ctx, { ...config, modes });
255
+ await sessionModes.init();
256
+ return sessionModes;
190
257
  }
191
258
 
192
259
  // 注册投影并拿读句柄。`register()` 要 await:句柄与注册同生命期(行卸载时自动释放),而读状态只能经句柄。
193
260
  private async init(): Promise<void> {
261
+ // 用户自己的模式定义(`{dshHome}/modes/*.toml`)覆盖已就位的那份 modes——内置默认由 `create` 在构造前补上。
262
+ const modes = this.config.modes as Record<string, SessionMode>;
263
+ for (const [id, value] of Object.entries(await loadModeOverrides())) {
264
+ // `noop` 是保留关键字:用户文件不能覆盖它。
265
+ if (id === "noop") continue;
266
+ // TOML 是部分字段:合并模板后过一遍 schema,新加的模式才有齐全的字段。
267
+ modes[id] = normalizeMode({ ...modes[id], ...value });
268
+ }
269
+ // 会话覆盖的持久化:harness 的存储域(与投影缓存同一层),按 sessionId 存一份配置。
270
+ // 没有这个服务(例如单测装配)时退化为纯内存,会话关闭即丢。
271
+ const storage = this.ctx.get("storageDomain") as unknown as
272
+ | {
273
+ open(spec: typeof sessionModeDomainSpec): Promise<{
274
+ close(): Promise<void>;
275
+ table(name: "sessions"): NonNullable<SessionModes["sessionsTable"]>;
276
+ }>;
277
+ }
278
+ | undefined;
279
+ if (storage !== undefined) {
280
+ const domain = await storage.open(sessionModeDomainSpec);
281
+ this.ctx.effect(() => () => domain.close(), "session-mode.domainClose");
282
+ this.sessionsTable = domain.table("sessions");
283
+ }
194
284
  this.projectionHandle = await this.ctx.sessionProjections.register(sessionModeProjection);
195
285
  this.editableHandle = await this.ctx.sessionProjections.register(sessionModeEditableProjection);
196
286
  // 上游那份 `modelSelection` 由 agent-loop 注册;这里拿同一个定义再注册一次只是要一个读句柄
@@ -267,11 +357,376 @@ export class SessionModes extends Service {
267
357
  return this.editableHandle.stateOf(session);
268
358
  }
269
359
 
360
+ // 每一条目的三态:模式默认下的判定 + 这个会话是否显式覆盖了它。
361
+ private buildCatalog(
362
+ agent: Agent | undefined,
363
+ base: SessionMode,
364
+ merged: SessionMode,
365
+ registered: readonly { name: string; description: string }[],
366
+ visible: readonly { name: string; description?: string }[],
367
+ ): SessionModeDirectory {
368
+ const entry = (
369
+ id: string,
370
+ description: string,
371
+ group: string | undefined,
372
+ allowKey: "allowTools" | "allowSkills" | "allowPolicies",
373
+ denyKey: "denyTools" | "denySkills" | "denyPolicies",
374
+ ): SessionModeDirectoryEntry => {
375
+ // `fallback` 反映**配置**:显式禁止(含组引用)→ deny;白名单内 → allow;白名单外 → deny;没配 → default。
376
+ const fallback: SessionModeTriState | "default" = deniedByList(base[denyKey], id)
377
+ ? "deny"
378
+ : base[allowKey].length > 0
379
+ ? base[allowKey].includes(id)
380
+ ? "allow"
381
+ : "deny"
382
+ : "default";
383
+ return {
384
+ id,
385
+ description,
386
+ ...(group === undefined ? {} : { group, groupLabel: toolGroupLabel(group) }),
387
+ fallback,
388
+ // override = 生效值与配置值不一致时的那一档(会话覆盖就是 `ModeSession` 本身)。
389
+ override: (() => {
390
+ const baseDeny = deniedByList(base[denyKey], id);
391
+ const effDeny = deniedByList(merged[denyKey], id);
392
+ if (baseDeny !== effDeny) return effDeny ? ("deny" as const) : ("allow" as const);
393
+ const baseAllow = base[allowKey].includes(id);
394
+ const effAllow = merged[allowKey].includes(id);
395
+ if (baseAllow !== effAllow) return effAllow ? ("allow" as const) : ("deny" as const);
396
+ return null;
397
+ })(),
398
+ };
399
+ };
400
+ // 收口时抓的全量目录 + 之后才注册的可见工具(agent-team 这类 scoped 工具),同一名字只留一份。
401
+ const known = new Map<string, { name: string; description: string }>();
402
+ if (agent !== undefined) {
403
+ for (const tool of this.scope.catalogOf(agent)) known.set(tool.name, tool);
404
+ for (const tool of visible) {
405
+ if (!known.has(tool.name)) {
406
+ known.set(tool.name, { name: tool.name, description: tool.description ?? "" });
407
+ }
408
+ }
409
+ }
410
+ const tools = [...known.values()].map((tool) =>
411
+ entry(tool.name, tool.description, toolGroupOf(tool.name), "allowTools", "denyTools"),
412
+ );
413
+ const toolIds = new Set(tools.map((tool) => tool.id));
414
+ const fsActive = ["read", "write", "edit", "apply_patch"].some((id) => toolIds.has(id));
415
+ return {
416
+ tools,
417
+ skills: registered.map((skill) =>
418
+ entry(skill.name, skill.description, undefined, "allowSkills", "denySkills"),
419
+ ),
420
+ // 两条 policy 都作用于文件工具:这个会话连一件文件工具都没有时,它们"不适用",按禁止显示。
421
+ policies: POLICY_NAMES.map((policy) => {
422
+ const row = entry(
423
+ policy,
424
+ POLICY_DESCRIPTIONS[policy],
425
+ undefined,
426
+ "allowPolicies",
427
+ "denyPolicies",
428
+ );
429
+ return fsActive ? row : { ...row, fallback: "deny" as const, override: null };
430
+ }),
431
+ };
432
+ }
433
+
434
+ // 这个会话在模式名单收窄之后"程序内可用"的工具名(给 `tool-guidance` 的注入判据用)。
435
+ knownToolsOf(agent: Agent): readonly string[] {
436
+ return this.scope.knownTools(agent);
437
+ }
438
+
270
439
  // 会话当前模式的定义。
271
440
  modeOfSession(session: Session): SessionMode {
272
441
  return this.definition(this.modeOf(session));
273
442
  }
274
443
 
444
+ // 会话覆盖的持久化:**不写会话日志**,落在 `storageDomain` 的 `session_mode` 域(按 `SessionId` 一行)。
445
+ private record(agent: Agent): void {
446
+ const session = this.modeOverrides.get(agent);
447
+ if (this.sessionsTable === undefined) return;
448
+ if (session === undefined) this.sessionsTable.delete(agent.session.id);
449
+ else this.sessionsTable.put(agent.session.id, { config: { ...session } });
450
+ }
451
+
452
+ // 进入会话时把覆盖读回来(异步,读到后重装一次)。
453
+ private hydrate(agent: Agent): void {
454
+ if (this.modeOverrides.has(agent) || this.sessionsTable === undefined) return;
455
+ const row = this.sessionsTable.get(agent.session.id);
456
+ if (row === undefined) return;
457
+ this.modeOverrides.set(agent, row.config as unknown as ModeSession);
458
+ const modeId = this.resolveModeId(agent);
459
+ this.installs.get(agent)?.dispose();
460
+ this.installs.delete(agent);
461
+ this.installFor(agent, modeId);
462
+ }
463
+
464
+ // 一次应用整份会话覆盖(面板的"应用到会话"):`ModeSession` 直接落,只重装一次。
465
+ // 一次应用整份会话覆盖(面板的"应用到会话"):参数就是一份完整的 `ModeConfig`。
466
+ // 把草稿写回**配置**(面板的"保存为默认"):只动模式的配置,不把会话覆盖混进来。
467
+ async saveConfig(
468
+ sessionId: SessionId,
469
+ config: ModeConfig,
470
+ ): Promise<SessionModeActiveState | undefined> {
471
+ const agent = this.ctx.agents.get(sessionId);
472
+ if (agent === undefined) return undefined;
473
+ const modeId = this.modeOf(agent.session);
474
+ (this.config.modes as Record<string, SessionMode>)[modeId] = config as unknown as SessionMode;
475
+ await writeMode(modeId, plainMode({ ...config }));
476
+ this.modeOverrides.delete(agent);
477
+ this.record(agent);
478
+ this.installs.get(agent)?.dispose();
479
+ this.installs.delete(agent);
480
+ this.installFor(agent, modeId);
481
+ return this.activeState(sessionId);
482
+ }
483
+
484
+ async applyConfig(
485
+ sessionId: SessionId,
486
+ config: ModeConfig,
487
+ ): Promise<SessionModeActiveState | undefined> {
488
+ const agent = this.ctx.agents.get(sessionId);
489
+ if (agent === undefined) return undefined;
490
+ this.modeOverrides.set(agent, structuredClone(config));
491
+ this.record(agent);
492
+ const modeId = this.resolveModeId(agent);
493
+ this.installs.get(agent)?.dispose();
494
+ this.installs.delete(agent);
495
+ this.installFor(agent, modeId);
496
+ return this.activeState(sessionId);
497
+ }
498
+
499
+ // 逐条目三态:`null` 清掉覆盖、回到模式默认。
500
+ // 这个会话可见的工具名全集(含被收窄的):组引用展开要用。
501
+ private toolNames(agent: Agent): readonly string[] {
502
+ const tools = this.ctx.get("tools") as unknown as
503
+ | { definitions(): readonly { name: string }[] }
504
+ | undefined;
505
+ const known = new Set((tools?.definitions() ?? []).map((tool) => tool.name));
506
+ for (const tool of this.scope.catalogOf(agent)) known.add(tool.name);
507
+ return [...known];
508
+ }
509
+
510
+ // 模式的可编辑面(面板的默认值与"另存为"的底稿)。
511
+ private editableFaceOf(mode: SessionMode): SessionModeEditable {
512
+ return {
513
+ persona: { ...mode.persona },
514
+ allowTools: [...mode.allowTools],
515
+ denyTools: [...mode.denyTools],
516
+ allowSkills: [...mode.allowSkills],
517
+ denySkills: [...mode.denySkills],
518
+ allowPolicies: [...mode.allowPolicies],
519
+ denyPolicies: [...mode.denyPolicies],
520
+ instructions: mode.instructions,
521
+ skills: mode.skills ?? true,
522
+ runtimeContext: mode.runtimeContext,
523
+ };
524
+ }
525
+
526
+ // 这个会话实际生效的模式定义:配置 + 会话覆盖(`ModeSession`),没有第二套逐条目结构。
527
+ private definitionFor(agent: Agent, modeId: string): SessionMode {
528
+ const base = this.definition(modeId);
529
+ const normalized = asModeConfig(base, base.skills ?? true);
530
+ const session = this.modeOverrides.get(agent);
531
+ // 覆盖是**全量**的一份配置;旧日志里可能缺字段,按配置补齐后再用。
532
+ return resolveModeConfig(
533
+ normalized,
534
+ session === undefined ? undefined : { ...normalized, ...session },
535
+ );
536
+ }
537
+
538
+ // 会话级覆盖里那几项(只列还覆盖着的)。
539
+ private overriddenKeys(agent: Agent | undefined): SessionModeSwitchKey[] {
540
+ if (agent === undefined) return [];
541
+ const override = this.modeOverrides.get(agent);
542
+ if (override === undefined) return [];
543
+ return (Object.keys(override) as SessionModeSwitchKey[]).filter(
544
+ (key) => override[key] !== undefined,
545
+ );
546
+ }
547
+
548
+ private overriddenFields(agent: Agent | undefined): string[] {
549
+ if (agent === undefined) return [];
550
+ const override = this.modeOverrides.get(agent);
551
+ return override === undefined ? [] : Object.keys(override);
552
+ }
553
+
554
+ // 改这个会话的可编辑字段(会话级覆盖);`null` 清掉该字段。
555
+ async setModePatch(
556
+ sessionId: SessionId,
557
+ patch: ModePatch,
558
+ ): Promise<SessionModeActiveState | undefined> {
559
+ const agent = this.ctx.agents.get(sessionId);
560
+ if (agent === undefined) return undefined;
561
+ const next: Record<string, unknown> = { ...this.modeOverrides.get(agent) };
562
+ for (const [key, value] of Object.entries(patch)) {
563
+ if (value === null) Reflect.deleteProperty(next, key);
564
+ else next[key] = value;
565
+ }
566
+ this.modeOverrides.set(agent, next as unknown as ModeSession);
567
+ const modeId = this.resolveModeId(agent);
568
+ this.record(agent);
569
+ this.installs.get(agent)?.dispose();
570
+ this.installs.delete(agent);
571
+ this.installFor(agent, modeId);
572
+ return this.activeState(sessionId);
573
+ }
574
+
575
+ // 改这个会话的三个注入开关:立刻重装这一份(工具面与注入面同时换),返回新的生效态。
576
+ async setSwitches(
577
+ sessionId: SessionId,
578
+ patch: SwitchPatch,
579
+ ): Promise<SessionModeActiveState | undefined> {
580
+ const agent = this.ctx.agents.get(sessionId);
581
+ if (agent === undefined) return undefined;
582
+ return this.setModePatch(sessionId, patch as ModePatch);
583
+ }
584
+
585
+ // 把当前会话的开关覆盖写回这个模式的配置(profile 的那份 patch):对新会话生效。
586
+ async saveAsDefault(sessionId: SessionId): Promise<SessionModeActiveState | undefined> {
587
+ const agent = this.ctx.agents.get(sessionId);
588
+ if (agent === undefined) throw new Error("会话不存在。");
589
+ const overrides = this.modeOverrides.get(agent);
590
+ if (overrides === undefined) return;
591
+ const modeId = this.modeOf(agent.session);
592
+ // 写回的是**当下生效**的那一份:模式的 persona/开关 + 逐条目的三态(合并成名单)。
593
+ const editable = this.editableFaceOf(this.definitionFor(agent, modeId));
594
+ const next = plainMode({ ...this.config.modes[modeId], ...editable });
595
+ (this.config.modes as Record<string, SessionMode>)[modeId] = next as unknown as SessionMode;
596
+ await writeMode(modeId, next);
597
+ // 写回后清掉会话覆盖:`config` 已在上面更新,重装用的是新默认,面板与工具面都不会回退。
598
+ this.modeOverrides.delete(agent);
599
+ this.installs.get(agent)?.dispose();
600
+ this.installs.delete(agent);
601
+ this.installFor(agent, modeId);
602
+ return this.activeState(sessionId);
603
+ }
604
+
605
+ // 以当前会话的生效配置另存为一个新模式;返回新 id。
606
+ async saveAsNewMode(sessionId: SessionId, code: string, name?: string): Promise<string> {
607
+ const agent = this.ctx.agents.get(sessionId);
608
+ if (agent === undefined) throw new Error("会话不存在。");
609
+ const id = code.trim();
610
+ if (id.length === 0) throw new Error("模式 code 不能为空。");
611
+ if (!/^[a-zA-Z0-9_-]+$/u.test(id))
612
+ throw new Error("模式 code 只能包含字母、数字、下划线与连字符。");
613
+ if (this.config.modes[id] !== undefined) throw new Error(`模式 ${id} 已存在。`);
614
+ const merged = this.definitionFor(agent, this.resolveModeId(agent));
615
+ const next = plainMode({ ...merged, name: name?.trim() || id });
616
+ // 先落内存再写文件:当前进程立刻可用,不必等 Loader(cordis 只是默认模板,不再参与写回)。
617
+ (this.config.modes as Record<string, SessionMode>)[id] = next as unknown as SessionMode;
618
+ await writeMode(id, next);
619
+ return id;
620
+ }
621
+
622
+ // 删除一个"另存为"出来的模式;内置模式拒绝。会话回到部署默认。
623
+ async deleteMode(sessionId: SessionId): Promise<string> {
624
+ const agent = this.ctx.agents.get(sessionId);
625
+ if (agent === undefined) throw new Error("会话不存在。");
626
+ const modeId = this.modeOf(agent.session);
627
+ if (modeId === this.defaultId) throw new Error("部署默认模式不能删除。");
628
+ const remaining = Object.keys(this.config.modes).filter((id) => id !== modeId);
629
+ if (remaining.length === 0) throw new Error("至少要留一个模式。");
630
+ const fallback = this.defaultId === modeId ? (remaining[0] as string) : this.defaultId;
631
+ Reflect.deleteProperty(this.config.modes as Record<string, SessionMode>, modeId);
632
+ await removeMode(modeId);
633
+ this.modeOverrides.delete(agent);
634
+ if (this.projectionHandle.stateOf(agent.session) === modeId) {
635
+ agent.session.append("session-mode/selected", { sessionMode: fallback });
636
+ }
637
+ this.installs.get(agent)?.dispose();
638
+ this.installFor(agent, fallback);
639
+ return fallback;
640
+ }
641
+
642
+ // 某个会话**当下生效**的一份:工具是运行期实际可见的集合(按需加载的 MCP 组加载后才会出现),
643
+ // 技能与 policy 是名单合成后的结果。侧边栏面板读它,不读配置清单。
644
+ async activeState(sessionId: SessionId): Promise<SessionModeActiveState | undefined> {
645
+ const session = this.ctx.sessions.get(sessionId);
646
+ if (session === undefined) return undefined;
647
+ const agent = this.ctx.agents.get(sessionId);
648
+ const id = this.modeOf(session);
649
+ const mode = agent === undefined ? this.definition(id) : this.definitionFor(agent, id);
650
+ // `ctx.get` 给的是追踪代理:带 JS 私有字段的方法在代理上会抛,先解包成真服务。
651
+ const unwrap = <T>(value: T | undefined): T | undefined =>
652
+ value === undefined
653
+ ? undefined
654
+ : (((value as { [key: symbol]: unknown })[Symbol.for("cordis.original")] ?? value) as T);
655
+ const tools = unwrap(
656
+ this.ctx.get("tools") as unknown as
657
+ | {
658
+ schemas(agent: Agent): readonly { name: string }[];
659
+ definitions(): readonly { name: string; description?: string }[];
660
+ }
661
+ | undefined,
662
+ );
663
+ const skills = unwrap(
664
+ this.ctx.get("skills") as unknown as
665
+ | {
666
+ list(options?: {
667
+ scope?: Agent;
668
+ cwd?: string;
669
+ }): Promise<readonly { name: string; description: string }[]>;
670
+ }
671
+ | undefined,
672
+ );
673
+ // 技能面按 agent 收口与工作目录扫描:不传 scope 只读全局层,本部署会读成空。
674
+ const cwd = (agent?.session.header as { cwd?: unknown } | undefined)?.cwd;
675
+ const registered =
676
+ skills === undefined
677
+ ? []
678
+ : await skills
679
+ .list({
680
+ ...(agent === undefined ? {} : { scope: agent }),
681
+ ...(typeof cwd === "string" ? { cwd } : {}),
682
+ })
683
+ .catch((error: unknown) => {
684
+ this.ctx.logger.warn("session-mode: skills.list() failed %o", {
685
+ error: String(error),
686
+ });
687
+ return [];
688
+ });
689
+ const config = asModeConfig(this.definition(id), this.definition(id).skills ?? true);
690
+ const override = agent === undefined ? undefined : this.modeOverrides.get(agent);
691
+ // 会话覆盖是整份 `ModeConfig`:面板要按字段显示来源,得知道**哪些字段真的不同**。
692
+ const overriddenFields =
693
+ override === undefined
694
+ ? []
695
+ : (
696
+ [
697
+ ["persona", "persona"],
698
+ ["instructions", "instructions"],
699
+ ["skills", "skills"],
700
+ ["runtimeContext", "runtimeContext"],
701
+ ["allowTools", "allowTools"],
702
+ ["denyTools", "denyTools"],
703
+ ["allowSkills", "allowSkills"],
704
+ ["denySkills", "denySkills"],
705
+ ["allowPolicies", "allowPolicies"],
706
+ ["denyPolicies", "denyPolicies"],
707
+ ] as const
708
+ )
709
+ .filter(([key]) => JSON.stringify(override[key]) !== JSON.stringify(config[key]))
710
+ .map(([, field]) => field);
711
+ return {
712
+ mode: { id, name: mode.name, description: mode.description },
713
+ config,
714
+ session: override,
715
+ overriddenFields,
716
+ // 可删的判据:不是部署默认、且删完还留得下至少一个模式(内置模式有确认弹窗兜底)。
717
+ deletable: id !== this.defaultId && Object.keys(this.config.modes).length > 1,
718
+ catalog: this.buildCatalog(
719
+ agent,
720
+ this.definition(id),
721
+ mode,
722
+ registered,
723
+ tools === undefined
724
+ ? []
725
+ : [...tools.definitions(), ...(agent === undefined ? [] : tools.schemas(agent))],
726
+ ),
727
+ };
728
+ }
729
+
275
730
  // 这条 policy 规则在**这次调用**里还生效吗(`true` = 交给上游,`false` = 被这个模式禁用)。
276
731
  // 判据只有一处:`actor.agent` → `agent.session` → `modeOf`。认不出 agent(没有 agent 的直接调用)、或模式 id 在
277
732
  // config 里找不到(重挂前后的瞬间)时一律按"没配"读——拦截路径绝不抛错。
@@ -319,7 +774,10 @@ export class SessionModes extends Service {
319
774
  const agent = this.ctx.agents.get(sessionId);
320
775
  const registry = this.presetRegistry();
321
776
  if (agent !== undefined && registry !== undefined) {
322
- const target = this.presetTarget(definition.presetsOnly, this.presetOfAgent(registry, agent));
777
+ const target = this.presetTarget(
778
+ definition.presetsOnly ?? [],
779
+ this.presetOfAgent(registry, agent),
780
+ );
323
781
  if (target !== undefined) {
324
782
  // 官方那条路自己也会查空白窗口(`agent-preset/locked`);它切完会 emit `agent-preset/selected`,
325
783
  // 监听据此落事实并重装——所以这里先比一次投影,同一个值不写第二条。
@@ -372,6 +830,9 @@ export class SessionModes extends Service {
372
830
  // 把某个活着的 agent 切到某个模式(不要求空白会话:子代理创建时的继承走这里);
373
831
  // `options.record: false` 时不写会话日志(只改当前进程)。
374
832
  applyTo(agent: Agent, mode: string, options: { record?: boolean } = {}): void {
833
+ this.hydrate(agent);
834
+ // 只有模式真的变了才重装:同一模式重复 `applyTo`(`agent/created` 之后再调一次)不该再装配一遍,
835
+ // 否则每一步会多写一条 `system/message`。覆盖变化由 `hydrate` 自己重装。
375
836
  this.definition(mode);
376
837
  if (options.record !== false) {
377
838
  agent.session.append("session-mode/selected", { sessionMode: mode });
@@ -410,7 +871,7 @@ export class SessionModes extends Service {
410
871
  modeForPreset(preset: string | undefined): string | undefined {
411
872
  if (preset === undefined) return undefined;
412
873
  const owners = Object.keys(this.config.modes).filter((id) =>
413
- this.config.modes[id]?.presetsOnly.includes(preset),
874
+ (this.config.modes[id]?.presetsOnly ?? []).includes(preset),
414
875
  );
415
876
  return owners.length === 1 ? owners[0] : undefined;
416
877
  }
@@ -428,28 +889,27 @@ export class SessionModes extends Service {
428
889
  const installed = this.installs.get(agent);
429
890
  if (installed?.mode === modeId) return;
430
891
  installed?.dispose();
431
- const mode = this.definition(modeId);
432
- const disposers = [
433
- installPersona(agent, mode.persona),
434
- this.installDefaultModel(agent, modeId),
435
- ];
436
- this.installs.set(agent, {
437
- mode: modeId,
438
- dispose: () => {
439
- for (const dispose of disposers) dispose();
440
- },
441
- });
892
+ const mode = this.definitionFor(agent, modeId);
893
+ // persona 与默认模型兜底同样落在 `agent.ctx` 上:收回账挂在本行 + agent 两处(见 `./per-agent.ts`),
894
+ // 否则行重挂之后新实例会撞上 `deployment:persona-prefix` 那条 section。
895
+ const account = this.scope.account(agent);
896
+ account.add(installPersona(agent, mode.persona));
897
+ account.add(this.installDefaultModel(agent, modeId));
898
+ this.installs.set(agent, { mode: modeId, dispose: () => account.release() });
442
899
  // 收口要的那几项按模式定义整份推过去:`name` / 两份工具名单 / 两份技能名单 / 三个开关。`skills` 缺省时在这里
443
900
  // 按这份定义自己的工具名单推导(见 `derivedSkills`)——收口那一侧只认解析后的布尔。
444
901
  this.scope.apply(agent, {
445
902
  name: mode.name,
446
903
  allowTools: mode.allowTools,
447
904
  denyTools: mode.denyTools,
905
+ allowToolsOverride: [],
906
+ denyToolsOverride: [],
448
907
  allowSkills: mode.allowSkills,
449
908
  denySkills: mode.denySkills,
450
909
  instructions: mode.instructions,
451
910
  skills: mode.skills ?? derivedSkills(mode),
452
911
  runtimeContext: mode.runtimeContext,
912
+ dropSections: mode.dropSections ?? [],
453
913
  });
454
914
  }
455
915
 
@@ -532,6 +992,14 @@ function requestJson(request: HttpRequestLike): Promise<unknown> {
532
992
  });
533
993
  }
534
994
 
995
+ function queryValue(url: string | undefined, name: string): string | undefined {
996
+ if (url === undefined) return undefined;
997
+ const separator = url.indexOf("?");
998
+ if (separator < 0) return undefined;
999
+ const value = new URLSearchParams(url.slice(separator + 1)).get(name);
1000
+ return value === null || value.length === 0 ? undefined : value;
1001
+ }
1002
+
535
1003
  function requiredString(value: unknown, field: string): string {
536
1004
  if (typeof value !== "string" || value.length === 0) {
537
1005
  throw new TypeError(`${field} 必须是非空字符串。`);
@@ -539,6 +1007,20 @@ function requiredString(value: unknown, field: string): string {
539
1007
  return value;
540
1008
  }
541
1009
 
1010
+ // 一条 POST 是面板的写面还是"切换模式":键漏一个,请求就会掉到 `mode` 校验上,报"mode 必须是非空字符串"。
1011
+ export function isWriteRequest(record: Record<string, unknown>): boolean {
1012
+ return (
1013
+ typeof record["switches"] === "object" ||
1014
+ typeof record["modePatch"] === "object" ||
1015
+ typeof record["itemState"] === "object" ||
1016
+ typeof record["applyConfig"] === "object" ||
1017
+ typeof record["saveConfig"] === "object" ||
1018
+ (typeof record["saveAsNewMode"] === "object" && record["saveAsNewMode"] !== null) ||
1019
+ record["deleteMode"] === true ||
1020
+ record["saveAsDefault"] === true
1021
+ );
1022
+ }
1023
+
542
1024
  async function handleRoute(
543
1025
  modes: SessionModes,
544
1026
  request: HttpRequestLike,
@@ -546,7 +1028,18 @@ async function handleRoute(
546
1028
  ): Promise<void> {
547
1029
  try {
548
1030
  if (request.method === "GET") {
549
- respondJson(response, 200, modes.roster());
1031
+ // 带 `sessionId` 时给这个会话的生效态,否则是模式清单(页面与 composer chip 用后者)。
1032
+ const sessionId = queryValue(request.url, "sessionId");
1033
+ if (sessionId === undefined) {
1034
+ respondJson(response, 200, modes.roster());
1035
+ return;
1036
+ }
1037
+ const active = await modes.activeState(sessionId as SessionId);
1038
+ if (active === undefined) {
1039
+ respondJson(response, 404, { error: "会话不存在。" });
1040
+ return;
1041
+ }
1042
+ respondJson(response, 200, active);
550
1043
  return;
551
1044
  }
552
1045
  if (request.method === "POST") {
@@ -555,6 +1048,74 @@ async function handleRoute(
555
1048
  throw new TypeError("请求体必须是 JSON 对象。");
556
1049
  }
557
1050
  const record = body as Record<string, unknown>;
1051
+ // 面板的写面:`switches` 改这个会话的开关,`saveAsDefault` 把当前覆盖写回模式配置。
1052
+ if (isWriteRequest(record)) {
1053
+ const target = requiredString(record["sessionId"], "sessionId") as SessionId;
1054
+ const saved =
1055
+ record["saveAsDefault"] === true ? await modes.saveAsDefault(target) : undefined;
1056
+ if (saved !== undefined) {
1057
+ respondJson(response, 200, saved);
1058
+ return;
1059
+ }
1060
+ if (record["deleteMode"] === true) {
1061
+ await modes.deleteMode(target);
1062
+ const state = await modes.activeState(target);
1063
+ respondJson(
1064
+ response,
1065
+ state === undefined ? 404 : 200,
1066
+ state ?? { error: "会话不存在。" },
1067
+ );
1068
+ return;
1069
+ }
1070
+ const saving = record["saveAsNewMode"];
1071
+ if (typeof saving === "object" && saving !== null) {
1072
+ const value = saving as { code?: unknown; name?: unknown };
1073
+ await modes.saveAsNewMode(
1074
+ target,
1075
+ requiredString(value.code, "code"),
1076
+ typeof value.name === "string" ? value.name : undefined,
1077
+ );
1078
+ }
1079
+ if (typeof record["saveConfig"] === "object" && record["saveConfig"] !== null) {
1080
+ const draft = record["saveConfig"] as { config?: ModeConfig };
1081
+ if (draft.config === undefined) {
1082
+ respondJson(response, 400, { error: "saveConfig 需要完整的 config。" });
1083
+ return;
1084
+ }
1085
+ const state = await modes.saveConfig(target, draft.config);
1086
+ respondJson(
1087
+ response,
1088
+ state === undefined ? 404 : 200,
1089
+ state ?? { error: "会话不存在。" },
1090
+ );
1091
+ return;
1092
+ }
1093
+ if (typeof record["applyConfig"] === "object" && record["applyConfig"] !== null) {
1094
+ const draft = record["applyConfig"] as { config?: ModeConfig };
1095
+ if (draft.config === undefined) {
1096
+ respondJson(response, 400, { error: "applyConfig 需要完整的 config。" });
1097
+ return;
1098
+ }
1099
+ const state = await modes.applyConfig(target, draft.config);
1100
+ respondJson(
1101
+ response,
1102
+ state === undefined ? 404 : 200,
1103
+ state ?? { error: "会话不存在。" },
1104
+ );
1105
+ return;
1106
+ }
1107
+ const patch = record["modePatch"] ?? record["switches"];
1108
+ const state =
1109
+ typeof patch === "object" && patch !== null
1110
+ ? await modes.setModePatch(target, patch as ModePatch)
1111
+ : await modes.activeState(target);
1112
+ if (state === undefined) {
1113
+ respondJson(response, 404, { error: "会话不存在。" });
1114
+ return;
1115
+ }
1116
+ respondJson(response, 200, state);
1117
+ return;
1118
+ }
558
1119
  const sessionId = requiredString(record["sessionId"], "sessionId") as SessionId;
559
1120
  const mode = requiredString(record["mode"], "mode");
560
1121
  const result: SessionModeSelectResult = { mode: await modes.select(sessionId, mode) };