diff --git "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md"
new file mode 100644
index 000000000..13bc1ab5a
--- /dev/null
+++ "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md"
@@ -0,0 +1,75 @@
+# TASK058 — DSH 与引擎深度对齐(相同 DTO 原则)
+
+> ADR:[0143](../adr/architecture/0143-dsh-web-frontend-hosting.md)(延续 TASK053 M2-d/M3 未竟项)
+> 目标:DSH 前端假定的宿主能力,逐项对齐到自有引擎真实数据源;宿主层直接消费引擎 DTO,消除平行存储与双源定义;GUI 作为唯一展示面。
+> 执行方式:按批次推进,每批走「红测试 → 实现 → 编译 → 绿测试 → 文档 → git 提交」。
+
+## 一、对齐差距清单(2026-10-21 双路调研结论)
+
+### 已对齐 ✅(真引擎数据,勿动)
+
+会话核心八件套(`list/create/rename/prompt[text+queue]/cancel/page/projections/modelCatalog`)+ workspace 全套 11 方法 + 4 流端点(`$events/session/follow/session/control/workspace/follow`)+ `directoryPicker/pick`。
+
+### P0 — 静默断裂(功能已坏且用户不可见)
+
+| # | 断裂点 | 现状 | 引擎闲置能力 |
+|---|--------|------|--------------|
+| 1 | 权限确认链 | DSH 宿主未注入 `PermissionConfirmationHandler` → `GuiPermissionConfirmationHandler.ConfirmAsync` fail-closed 静默 Deny,前端权限对话框永不弹出 | handler 已实现(旧 WebUI 消费);`approval/request` waterfall 事件白名单前端已定义 |
+| 2 | 模型切换 | `session/selectModel` 未实现;`ModelSelection.Next` 恒 null;模型选择器只读 | `SetModelAsync/SetVendorAsync` 闲置 |
+| 3 | 设置双源 | `DshSettingsStore` 进程内内存假 schema,重启即丢,与 settings.json 零互通 | `IConfigurationService` + `SettingsJson` DTO |
+| 4 | 权限模式 | `permissionPresets/catalog` 硬编码且选择无路由;宿主三元词汇与引擎 `PermissionMode` 枚举无映射 | `SetPermissionModeAsync` |
+| 5 | 流事件收窄 | `DshTurnRunner` 只消费 4/13 类:Thinking/ToolProgress/`Complete.Usage`/AgentStarted/Finished 全丢弃 | `ChatStreamEvent` 枚举齐全 |
+
+### P1 — 功能未实现但有引擎数据可读
+
+| # | 功能 | 现状 | 引擎闲置能力 |
+|---|------|------|--------------|
+| 6 | 斜杠命令 | `commands/list` 空数组、`commands/execute` 未实现 | `GetAvailableSlashCommands` + `ExecuteSlashCommandAsync` |
+| 7 | 子代理 | 15+ 接口成员零暴露;`session/follow` 子代理地址显式拒绝;AgentStarted/Finished 流事件丢弃 | `GetBackgroundAgentsAsync/InterruptSubAgentAsync/…` |
+| 8 | 会话管理 | `session/fork`、清空/回退无 | `ClearHistoryAsync/RewindLastTurnAsync` |
+| 9 | Provider 列表 | `llm/listProviders` 空数组 | `VendorModelMap` |
+| 10 | 逐字流式 | `assistant-stream` 恒 revision 0 | TASK043 已登记(引擎侧缺陷) |
+| 11 | steer | `mode≠queue` 返回 `session/busy` | 引擎未暴露轮内注入(0945d7d32 前提记录) |
+
+### P2 — 大块新功能(另行立项)
+附件(fileUploads/attachment)、文件树(workspaceFiles 5)、终端(10)、goals 6、`session/updateQueue`。
+
+### 不接 ❌(DSH 在线服务,与本项目无关)
+account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAnalytics、schedule、officeToPdf —— 保持 `unimplemented` 引导错误。
+
+## 二、DTO 统一原则(决策)
+
+1. **宿主直接消费引擎 DTO**:设置读写走引擎 `IConfigurationService`/`SettingsJson`;权限预设以引擎 `PermissionMode` 枚举为唯一数据源(`[EnumValue]` 扩展),宿主仅做枚举→前端词汇的一次性投影,禁止反向维护第二套枚举。
+2. **事件投影单点**:`ChatStreamEvent` → DSH 事件的映射只在 `DshTurnRunner`/`DshTranscriptProjector` 单点扩展,禁止在分发器散落 ad-hoc 转换。
+3. **消除平行存储**:`DshSettingsStore` 内存假数据退役,`DshModelCatalogSource` 的 settings.json 直读与引擎 attach 双来源保留但补 `RefreshVendorModelMap` 联动。
+4. JSON 一律 DTO + `JsonContext` 源生成(ADR 0133)。
+
+## 三、批次计划(每批一次 commit)
+
+| 批次 | 内容 | 状态 |
+|------|------|------|
+| B1 | 权限确认链闭环:注入 handler + `approval/request` waterfall + `$events/result` 回帖 + 权限预设接 `PermissionMode` 映射与 `SetPermissionModeAsync` 路由 | ✅ 单元+冒烟(选择路由落 B3 settings 统一;真机审批弹窗待手动验收) |
+| B2 | 模型切换:`session/selectModel` → `SetModelAsync/SetVendorAsync` + `llm/listProviders` 接 `VendorModelMap` | ✅ 单元(引擎分支真机验收) |
+| B3 | 设置统一:`DshSettingsStore` → dsh-settings.json 持久化 + permission/defaultPreset 路由 `SetPermissionModeAsync` | ✅ 单元 12/12 |
+| B4 | 流事件扩展:Thinking → reasoning 块 + `Complete.Usage` → data.usage 四桶(ToolProgress 暂缓) | ✅ 单元 4/4 |
+| B5 | 斜杠命令:`commands/list` 投影 [ChatCommand] 目录 + `commands/execute` 委托 SlashCommandRunner | ✅ 单元 5/5 |
+| B6 | 子代理流事件:AgentStarted/Finished → tool-workflow 事件族(subagentCatalog 运行实例目录另立任务) | ✅ 单元 7/7 |
+| B7 | 会话管理:`session/fork` 复制 meta/转录/journal(清空/回退无前端 RPC 消费点,不做) | ✅ 单元 3/3 |
+
+## 四列验收表
+
+| 基建实现 | 消费点 | 已实现 | 已验收 |
+|----------|--------|--------|--------|
+| B1 `DshApprovalBroker`(waterfall 帧/挂起结算/断开 fail-closed)+ `DshWebUiWindow.AttachEngineAsync` 注入 `PermissionConfirmationHandler` + `$events/result` 路由 + `permissionPresets/catalog` 枚举映射(`PermissionMode` 唯一数据源) | 前端权限对话框弹窗(approval/request)/权限预设选择器 | ✅ 单测 11/11(帧契约五键硬校验/三态 outcome/断开清理/路由引导错误/枚举投影)+ 冒烟 `$events` attach 日志 | ⬜ 真机:发触发权限确认的消息 → 审批面板弹出 → Enter 允许工具执行 |
+| B2 `session/selectModel` 路由 + `ModelSelection.Next` 真值 + `llm/listProviders` | 模型选择器切换/Provider 列表 | ⬜ | ⬜ |
+| B3 `DshSettingsStore` 改造读写引擎 settings.json | 前端设置页(general 等) | ⬜ | ⬜ |
+| B4 `DshTurnRunner` 事件映射扩展(5 类) | 思考块/工具进度/子代理卡片/token 统计 | ⬜ | ⬜ |
+| B5 `commands/list|execute` 路由 | 斜杠命令补全与执行 | ⬜ | ⬜ |
+| B6 agents/interrupt/follow 子代理 | 子代理面板/轨迹 | ⬜ | ⬜ |
+| B7 fork/clear/rewind | 会话菜单操作 | ⬜ | ⬜ |
+
+## 风险与前提
+
+- journal 双源「CLI 后写不可见」窗口:属引擎事件总线下沉课题(TASK053 已预警),本任务不处理,另行立项。
+- `assistant-stream` 逐字流式依赖 TASK043(引擎流式缓冲缺陷)修复,本任务 B4 只做事件级增量。
+- steer 依赖引擎暴露轮内注入接口(0945d7d32),本任务不实现。
diff --git a/modules/apps/gui/hosting/IJccChatSession.cs b/modules/apps/gui/hosting/IJccChatSession.cs
index 0fa9866ec..87b8676ba 100644
--- a/modules/apps/gui/hosting/IJccChatSession.cs
+++ b/modules/apps/gui/hosting/IJccChatSession.cs
@@ -192,6 +192,13 @@ public interface IJccChatSession : IAsyncDisposable {
///
Task SetPermissionModeAsync(PermissionMode mode, CancellationToken cancellationToken = default);
+ ///
+ /// 当前权限模式 — 读引擎 IToolPermissionManager.GetCurrentModeAsync(安全闸后的实际生效值)。
+ /// 未注册权限系统时返回 (DSH 权限预设目录默认值,TASK058 B1)。
+ ///
+ Task GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(PermissionMode.Auto);
+
///
/// 获取可用子代理清单 — 从 IAgentDefinitionProvider 提取全部代理定义
/// (内置 + 插件 + 用户.md + 项目.md),供 GUI @子代理补全消费。引擎未注册时返回空列表。
diff --git a/modules/apps/gui/hosting/JccChatSession.cs b/modules/apps/gui/hosting/JccChatSession.cs
index 8b371fa32..e81504e9d 100644
--- a/modules/apps/gui/hosting/JccChatSession.cs
+++ b/modules/apps/gui/hosting/JccChatSession.cs
@@ -246,6 +246,12 @@ public async Task SetPermissionModeAsync(PermissionMode mode, CancellationToken
await manager.SetPermissionModeAsync(mode, cancellationToken);
}
+ ///
+ public async Task GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default) {
+ var manager = _services.GetService();
+ return manager is not null ? await manager.GetCurrentModeAsync(cancellationToken) : PermissionMode.Auto;
+ }
+
/// settings.json 变更转发 — theme 键变更时解析为 ThemeKind 并触发 ThemeChanged
private void OnSettingChanged(object? sender, SettingChangeEventArgs e) {
if (e.Key == ConfigKeyEnumConstants.Theme && e.NewValue is not null) {
diff --git a/modules/apps/gui/web_host/DshAgentRunTracker.cs b/modules/apps/gui/web_host/DshAgentRunTracker.cs
new file mode 100644
index 000000000..3719f6b3d
--- /dev/null
+++ b/modules/apps/gui/web_host/DshAgentRunTracker.cs
@@ -0,0 +1,71 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// 子代理轮内运行追踪器(TASK058 B6,翻译单点):把引擎 AgentStarted/AgentFinished 流事件
+/// 投影为 DSH tool-workflow 事件族(前端 workflow-run 节点渲染子代理卡片)。
+/// 每轮一个合成 run(RunId 由调用方给出);首个成员自动补发 run-start;
+/// start/end 按 AgentId 配对轮内序号;未 start 的 end 静默丢弃。
+/// 值域契约:outcome = completed/cancelled/failed;stopReason = completed/cancelled/error。
+///
+internal sealed class DshAgentRunTracker(string runId) {
+ /// 发布事件(type + data 树)
+ internal sealed record WorkflowEvent(string Type, JsonElement Data);
+
+ private readonly Dictionary _seqByAgent = new(StringComparer.Ordinal);
+ private int _nextSeq;
+ private bool _runStarted;
+
+ /// 是否已发出过 run-start(有成员加入即视为有 run)
+ public bool RunStarted => _runStarted;
+
+ /// 处理一个 AgentStarted:首个自动先发 run-start,再发 agent-start(轮内序号 1 起)
+ internal List OnAgentStarted(ChatStreamEvent evt) {
+ if (string.IsNullOrEmpty(evt.AgentId)) {
+ return [];
+ }
+ var events = new List();
+ if (!_runStarted) {
+ _runStarted = true;
+ events.Add(new WorkflowEvent("tool-workflow/run-start", Serialize(new DshWorkflowRunStartData {
+ RunId = runId,
+ Name = "subagents",
+ })));
+ }
+ var seq = ++_nextSeq;
+ _seqByAgent[evt.AgentId] = seq;
+ events.Add(new WorkflowEvent("tool-workflow/agent-start", Serialize(new DshWorkflowAgentStartData {
+ RunId = runId,
+ Seq = seq,
+ Label = string.IsNullOrEmpty(evt.AgentName) ? evt.AgentId : evt.AgentName,
+ ChildId = evt.AgentId,
+ })));
+ return events;
+ }
+
+ /// 处理一个 AgentFinished:按 AgentId 配对序号发 agent-end(成功→completed,失败/未知→failed);未 start 的丢弃
+ internal List OnAgentFinished(ChatStreamEvent evt) {
+ if (string.IsNullOrEmpty(evt.AgentId) || !_seqByAgent.TryGetValue(evt.AgentId, out var seq)) {
+ return [];
+ }
+ return [
+ new WorkflowEvent("tool-workflow/agent-end", Serialize(new DshWorkflowAgentEndData {
+ RunId = runId,
+ Seq = seq,
+ Outcome = evt.AgentSuccess is true ? "completed" : "failed",
+ })),
+ ];
+ }
+
+ /// 轮次收尾:无成员返回 null(不发空 run);stopReason 值域 completed/cancelled/error
+ internal WorkflowEvent? BuildRunEnd(string stopReason) =>
+ _runStarted
+ ? new WorkflowEvent("tool-workflow/run-end", Serialize(new DshWorkflowRunEndData {
+ RunId = runId,
+ StopReason = stopReason,
+ }))
+ : null;
+
+ /// DTO → data 树(与 DshSessionStreamHub.Allocate 同款 GetTypeInfo 查表)
+ private static JsonElement Serialize(T data) =>
+ JsonSerializer.SerializeToElement(data, DshJsonContext.Default.GetTypeInfo(typeof(T)));
+}
diff --git a/modules/apps/gui/web_host/DshApprovalBroker.cs b/modules/apps/gui/web_host/DshApprovalBroker.cs
new file mode 100644
index 000000000..a4bc47bf5
--- /dev/null
+++ b/modules/apps/gui/web_host/DshApprovalBroker.cs
@@ -0,0 +1,128 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// DSH 权限确认桥(TASK058 B1,ADR 0143 M2-d):把引擎权限确认点接到 DSH 前端 approval 对话框。
+/// 链路:引擎 GuiPermissionConfirmationHandler.ConfirmAsync → 宿主注入委托 →
+/// $events 流发 waterfall 帧(approval/request)→ 前端 composer 内嵌审批面板 →
+/// POST /api/$events/result 回帖 → 本桥结算 → 决策返回引擎。
+/// fail-closed 全链路:$events 未连接/已断开/监听器无人处理(next)/监听器抛错(rejected)一律拒绝。
+/// 决策词汇:前端 allowed-once → Allow;rejected/其他 → Deny(前端无 always-allow 概念)。
+///
+internal sealed class DshApprovalBroker {
+ /// 一次挂起中的确认
+ private sealed record Pending(TaskCompletionSource Decision);
+
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _pending = new(StringComparer.Ordinal);
+
+ /// 当前 $events 连接的发送通道(null = 前端未连接/已断开);发送委托已绑定该流的 streamId
+ private (Func Send, string ClientId)? _events;
+
+ /// waterfall 帧的目标会话(轮次执行时由 DshTurnRunner 设置;缺省用 host 常量)
+ private volatile string? _agentId;
+
+ /// $events 流打开时挂接发送通道(连接级单例;旧连接被新连接替换)
+ /// 帧值发送委托(宿主闭包绑定 streamId,保证路由到该逻辑流)
+ /// 就绪帧下发的客户端标识
+ internal void Attach(Func send, string clientId) {
+ lock (_gate) { _events = (send, clientId); }
+ App.LogDiag($"[DSH-APPROVAL] events stream attached client={clientId}");
+ }
+
+ /// 连接状态读写锁(volatile 元组不可用;读写互斥保护 _events 与 _pending 的结算一致性)
+ private readonly object _gate = new();
+
+ /// $events 流关闭时摘除通道:挂起中的请求立即拒绝(不挂引擎轮次),后续请求直接拒绝
+ internal void Detach() {
+ lock (_gate) { _events = null; }
+ foreach (var (eventId, pending) in _pending) {
+ if (_pending.TryRemove(eventId, out _)) {
+ pending.Decision.TrySetResult(Hosting.PermissionConfirmationDecision.Deny);
+ }
+ }
+ App.LogDiag("[DSH-APPROVAL] events stream detached: pending requests denied");
+ }
+
+ /// 设置 waterfall 目标会话(DshTurnRunner 轮次开始时调用;前端按 agentId 路由到对应会话 scope)
+ internal void SetAgent(string? sessionId) => _agentId = sessionId;
+
+ ///
+ /// 引擎确认点入口:构造 waterfall 帧发给前端并等待回帖。
+ /// $events 未连接 → 立即拒绝;request 载荷只含 toolName/reason/callId(前端硬校验禁含 agent/signal)。
+ ///
+ /// 引擎确认请求
+ /// 前端决策(allowed-once=Allow;其余一律 Deny)
+ internal async Task RequestAsync(Hosting.PermissionConfirmationRequest request) {
+ var send = CurrentSend();
+ if (send is null) {
+ return Hosting.PermissionConfirmationDecision.Deny;
+ }
+
+ var eventId = Guid.NewGuid().ToString("N");
+ var frame = new DshWaterfallFrame {
+ Event = "approval/request",
+ EventId = eventId,
+ AgentId = _agentId ?? "host",
+ Request = JsonSerializer.SerializeToElement(
+ new DshApprovalRequestBody {
+ ToolName = request.ToolName,
+ Reason = request.ConfirmationPrompt,
+ CallId = request.RequestId,
+ }, DshJsonContext.Default.DshApprovalRequestBody),
+ };
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pending[eventId] = new Pending(completion);
+ try {
+ await send(JsonSerializer.SerializeToElement(frame, DshJsonContext.Default.DshWaterfallFrame));
+ App.LogDiag($"[DSH-APPROVAL] waterfall sent event=approval/request id={eventId} tool={request.ToolName}");
+ } catch (Exception ex) {
+ _pending.TryRemove(eventId, out _);
+ App.LogDiag($"[DSH-APPROVAL] waterfall send failed: {ex.Message}");
+ return Hosting.PermissionConfirmationDecision.Deny;
+ }
+
+ return await completion.Task;
+ }
+
+ /// 同步读取当前发送通道(lock 收敛在同步方法内,规避 async 方法持锁)
+ private Func? CurrentSend() {
+ lock (_gate) { return _events?.Send; }
+ }
+
+ ///
+ /// 前端回帖结算:kind=result 且值为 allowed-once → Allow;next/rejected/其他值 → Deny。
+ /// clientId 与当前 $events 连接不符(前端重连后的旧代际回帖)或未知 eventId(已结算/从未发起)返回 false(幂等,不抛错)。
+ ///
+ /// 回帖声称的客户端标识(校验与当前连接一致)
+ /// waterfall 帧的 eventId
+ /// 监听器处理结果
+ /// 是否结算了一个挂起中的请求
+ internal Task SettleAsync(string clientId, string eventId, DshEventOutcome? outcome) {
+ lock (_gate) {
+ if (_events is not { } channel || !string.Equals(channel.ClientId, clientId, StringComparison.Ordinal)) {
+ return Task.FromResult(false);
+ }
+ }
+ if (!_pending.TryRemove(eventId, out var pending)) {
+ return Task.FromResult(false);
+ }
+
+ var decision = Hosting.PermissionConfirmationDecision.Deny;
+ if (outcome is { Kind: "result" } && outcome.Value is { ValueKind: JsonValueKind.String } value
+ && value.GetString() == "allowed-once") {
+ decision = Hosting.PermissionConfirmationDecision.Allow;
+ }
+ pending.Decision.TrySetResult(decision);
+ App.LogDiag($"[DSH-APPROVAL] settled id={eventId} kind={outcome?.Kind} value={outcome?.Value} -> {decision}");
+ return Task.FromResult(true);
+ }
+}
+
+/// approval/request 的前端载荷(dsh-client-ui-approval PendingApproval 消费 toolName/callId/reason/displayReason)
+internal sealed class DshApprovalRequestBody {
+ /// 工具名
+ public string ToolName { get; set; } = "";
+ /// 人类可读原因(引擎确认提示文本)
+ public string Reason { get; set; } = "";
+ /// 关联的调用 ID(可选)
+ public string? CallId { get; set; }
+}
diff --git a/modules/apps/gui/web_host/DshAssistantReplyProjection.cs b/modules/apps/gui/web_host/DshAssistantReplyProjection.cs
new file mode 100644
index 000000000..a1a32c074
--- /dev/null
+++ b/modules/apps/gui/web_host/DshAssistantReplyProjection.cs
@@ -0,0 +1,41 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// 轮次事件 → DSH 助手消息投影(TASK058 B4):引擎流事件的收窄翻译单点。
+/// Thinking → content 前置 reasoning 块(前端 toAssistantBlock type=reasoning 渲染思考折叠区);
+/// Complete.Usage()→ 四桶(前端 TurnUsagePanel 消费,
+/// totalTokens = 四桶之和对齐前端 tokenTotal 语义)。
+///
+internal static class DshAssistantReplyProjection {
+ /// 把一轮的思考/正文/用量投影为前端消息块序列与用量
+ /// 思考内容(null 或空表示无思考)
+ /// 正文(可为空)
+ /// 引擎 Complete 事件的用量(null 表示未报告)
+ /// 消息块列表与用量 DTO
+ internal static (List Content, DshTurnUsage? Usage) Project(string? thinking, string text, TokenUsage? usage) {
+ List content = [];
+ if (!string.IsNullOrEmpty(thinking)) {
+ content.Add(new DshWireContentBlock { Type = "reasoning", Text = thinking });
+ }
+ if (!string.IsNullOrEmpty(text)) {
+ content.Add(new DshWireContentBlock { Type = "text", Text = text });
+ }
+ return (content, MapUsage(usage));
+ }
+
+ /// 引擎 TokenUsage → 前端四桶(null 透传;未缓存输入 = prompt − cacheRead)
+ private static DshTurnUsage? MapUsage(TokenUsage? usage) {
+ if (usage is null) {
+ return null;
+ }
+ var uncachedInput = Math.Max(0, usage.PromptTokens - usage.CacheReadInputTokens);
+ return new DshTurnUsage {
+ UncachedInputTokens = uncachedInput,
+ OutputTokens = usage.CompletionTokens,
+ CacheReadTokens = usage.CacheReadInputTokens,
+ CacheWriteTokens = usage.CacheCreationInputTokens,
+ TotalTokens = uncachedInput + usage.CompletionTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens,
+ ReasoningTokens = usage.ReasoningTokens,
+ };
+ }
+}
diff --git a/modules/apps/gui/web_host/DshCommandsProjection.cs b/modules/apps/gui/web_host/DshCommandsProjection.cs
new file mode 100644
index 000000000..215b4de3e
--- /dev/null
+++ b/modules/apps/gui/web_host/DshCommandsProjection.cs
@@ -0,0 +1,17 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// 斜杠命令元数据 → DSH 命令目录投影(TASK058 B5,翻译单点):
+/// 引擎 (Name 含 / 前缀)→ 前端 /{name} 组装约定(去前缀);
+/// → input.hint(补全提示)。
+///
+internal static class DshCommandsProjection {
+ /// 投影单条命令元数据(无参数提示时省略 input)
+ internal static DshCommandInfo Project(SlashCommandMetadata metadata) => new() {
+ Name = metadata.Name.TrimStart('/'),
+ Description = metadata.Description,
+ Input = string.IsNullOrEmpty(metadata.ArgumentHint)
+ ? null
+ : new DshCommandInputInfo { Hint = metadata.ArgumentHint },
+ };
+}
diff --git a/modules/apps/gui/web_host/DshHostServices.cs b/modules/apps/gui/web_host/DshHostServices.cs
index 3e9cdf99f..a58685181 100644
--- a/modules/apps/gui/web_host/DshHostServices.cs
+++ b/modules/apps/gui/web_host/DshHostServices.cs
@@ -12,11 +12,12 @@ internal sealed class DshHostServices {
/// 宿主事件日志目录(默认 ~/.jcc/dsh_journal)
internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = null, string? journalDirectory = null) {
Workspaces = new DshWorkspaceStore(fileSystem, workspaceFilePath);
- Settings = new DshSettingsStore();
+ Settings = new DshSettingsStore(fileSystem);
Sessions = new DshSessionStore();
Models = new DshModelCatalogSource();
Journal = new DshSessionJournal(fileSystem, journalDirectory ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_journal"));
Streams = new DshSessionStreamHub(Journal);
+ Approvals = new DshApprovalBroker();
}
/// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误)
@@ -43,4 +44,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul
/// 模型目录(session/modelCatalog — 启动读 settings.json,引擎就绪后热切换)
internal DshModelCatalogSource Models { get; }
+
+ /// 权限确认桥(引擎确认点 → $events waterfall → 前端回帖,TASK058 B1)
+ internal DshApprovalBroker Approvals { get; }
}
diff --git a/modules/apps/gui/web_host/DshRpcDispatcher.cs b/modules/apps/gui/web_host/DshRpcDispatcher.cs
index c0674ddb5..d85a563ce 100644
--- a/modules/apps/gui/web_host/DshRpcDispatcher.cs
+++ b/modules/apps/gui/web_host/DshRpcDispatcher.cs
@@ -31,18 +31,22 @@ internal DshRpcDispatcher(DshHostServices services) {
["session/prompt"] = SessionPrompt,
["session/cancel"] = Sync(args => SessionCancel(args, CancellationToken.None)),
["session/modelCatalog"] = Sync(SessionModelCatalog),
+ ["session/selectModel"] = SessionSelectModel,
+ ["session/fork"] = SessionFork,
["session/projections"] = SessionProjections,
["session/page"] = SessionPage,
["agentPresets/list"] = Sync(_ => Ok(new DshAgentPresetList {
Presets = [new DshAgentPreset { Id = "standard", Order = 1, IsDefault = true }],
})),
- ["permissionPresets/catalog"] = Sync(_ => Ok(DefaultPermissionPresets())),
+ ["permissionPresets/catalog"] = PermissionPresetsCatalog,
+ ["$events/result"] = EventsResult,
["settings/describe"] = Sync(_ => Ok(services.Settings.Describe())),
["settings/mutate"] = Sync(args => services.Settings.Mutate(args)),
["credentials/describe"] = Sync(Credentials),
- ["llm/listProviders"] = Sync(_ => Ok(new List())),
+ ["llm/listProviders"] = Sync(LlmListProviders),
["llm/listConfigurableProviders"] = Sync(_ => OkEmptyArray()),
- ["commands/list"] = Sync(_ => OkEmptyArray()),
+ ["commands/list"] = Sync(CommandsList),
+ ["commands/execute"] = CommandsExecute,
["messageFeedback/list"] = Sync(_ => Ok(new DshMessageFeedbackListResult { Value = new DshMessageFeedbackListValue() })),
["skills/list"] = Sync(_ => Ok(new DshSkillList())),
["dynamicCordisRunner/inventory"] = Sync(_ => OkEmptyArray()),
@@ -286,6 +290,86 @@ private async Task SessionPage(JsonElement args, CancellationToken
/// session/modelCatalog:自有引擎的供应商/模型目录
private DshRpcResult SessionModelCatalog(JsonElement args) => Ok(_services.Models.Current);
+ ///
+ /// session/selectModel:切换会话模型(TASK058 B2)。供应商变化时先 SetVendorAsync
+ /// (引擎重置默认模型并持久化)再 SetModelAsync 覆盖;reasoningEffort 按引擎枚举值解析,
+ /// 未知值忽略不报错。响应回显实际生效的供应商/模型。
+ ///
+ private async Task SessionSelectModel(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionSelectModelArgs)?.Request;
+ if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) {
+ return Fail("session/rejected", $"session/selectModel 需要 {{sessionId, provider, model}}(reasoningEffort 可选);收到 args={Describe(args)}");
+ }
+ if (string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.Model)) {
+ return Fail("session/rejected", $"session/selectModel 的 provider 与 model 均为必填;收到 provider={request.Provider} model={request.Model}");
+ }
+ if (_services.Engine is not { } engine) {
+ return Fail("session/not-ready", "引擎仍在组装中,稍后重试;若长时间未就绪查看 dumps/startup_timing.log");
+ }
+ if (!string.Equals(engine.CurrentVendor, request.Provider, StringComparison.OrdinalIgnoreCase)) {
+ await engine.SetVendorAsync(request.Provider, cancellationToken);
+ }
+ await engine.SetModelAsync(request.Model, cancellationToken);
+ if (request.ReasoningEffort is { } effort && EffortLevelExtensions.FromValue(effort) is { } level) {
+ await engine.SetEffortLevelAsync(level, cancellationToken);
+ }
+ return Ok(new DshSessionSelectModelResult {
+ Selected = new DshModelChoice {
+ Provider = engine.CurrentVendor,
+ Model = engine.CurrentModelId,
+ ReasoningEffort = request.ReasoningEffort,
+ },
+ });
+ }
+
+ ///
+ /// session/fork:分叉会话(TASK058 B7)。复制源会话 meta/转录/journal 到新 ID(v1 全量复制,
+ /// atSeq 截断为深化项);源会话不受影响。未知会话返回引导错误。
+ ///
+ private async Task SessionFork(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionForkArgs)?.Request;
+ if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) {
+ return Fail("session/rejected", $"session/fork 需要 {{sessionId, atSeq?}};收到 args={Describe(args)}");
+ }
+ if (!_services.Sessions.IsReady) {
+ return Fail("session/not-ready", "会话存储尚未就绪(引擎仍在组装中),稍后重试即可");
+ }
+ var newId = await _services.Sessions.ForkAsync(request.SessionId, _services.Journal, cancellationToken);
+ return newId is null
+ ? Fail("session/unknown", $"会话 {request.SessionId} 不在磁盘上(~/.jcc/sessions/{request.SessionId}/transcript.json 不存在);从侧栏选择一个已有会话再分叉")
+ : Ok(new DshSessionForkResult { SessionId = newId });
+ }
+
+ /// llm/listProviders:模型目录分组投影(与 session/modelCatalog 同源,TASK058 B2)
+ private DshRpcResult LlmListProviders(JsonElement args) => Ok(_services.Models.Current.Groups
+ .Select(group => new DshProviderInfo { Id = group.Id, Name = group.Name })
+ .ToList());
+
+ /// commands/list:引擎斜杠命令目录投影(TASK058 B5 — 引擎 [ChatCommand] 特性为唯一数据源)
+ private DshRpcResult CommandsList(JsonElement args) => Ok(
+ (_services.Engine?.GetAvailableSlashCommands() ?? [])
+ .Select(DshCommandsProjection.Project)
+ .ToList());
+
+ ///
+ /// commands/execute:执行斜杠命令(TASK058 B5)。委托引擎 SlashCommandRunner(与 CLI/TUI 同一链路);
+ /// v1 不支持附件参数。成功与未知命令都带输出文本(前端即时反馈渲染)。
+ ///
+ private async Task CommandsExecute(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshCommandsExecuteArgs);
+ if (request is null || string.IsNullOrWhiteSpace(request.AgentId) || string.IsNullOrWhiteSpace(request.Line)) {
+ return Fail("commands/rejected", $"commands/execute 需要 {{agentId, line}};收到 args={Describe(args)}(附件参数暂不支持)");
+ }
+ if (_services.Engine is not { } engine) {
+ return Fail("commands/not-ready", "引擎仍在组装中,稍后重试;若长时间未就绪查看 dumps/startup_timing.log");
+ }
+ var output = await engine.ExecuteSlashCommandAsync(request.Line, cancellationToken);
+ return Ok(new DshCommandsExecuteResult {
+ CommandId = request.Line.Split(' ', 2)[0].TrimStart('/'),
+ Result = new DshCommandOutcome { Kind = "success", Text = output },
+ });
+ }
+
/// credentials/describe:按环境变量判断是否已配置
private static DshRpcResult Credentials(JsonElement args) =>
Ok(ReadStrings(args, "refs").ToDictionary(
@@ -293,18 +377,37 @@ private static DshRpcResult Credentials(JsonElement args) =>
name => new DshCredentialState { Configured = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name)) },
StringComparer.Ordinal));
- /// 权限预设目录:与 DSH 默认值一致(read-only / workspace-write / danger-full-access)
- private static DshPermissionPresetCatalog DefaultPermissionPresets() {
- List options = [
- new() { Value = "read-only", Name = "read-only" },
- new() { Value = "workspace-write", Name = "workspace-write" },
- new() { Value = "danger-full-access", Name = "danger-full-access" },
- ];
- return new DshPermissionPresetCatalog {
+ ///
+ /// $events/result:前端对 waterfall 帧的回帖(approval 决策等)。
+ /// 载荷 {clientId, eventId, outcome};结算结果对前端不可见(回帖 RPC 只需 ok),
+ /// 未知 eventId / clientId 不符幂等返回 ok(bridge 内部记录)。
+ ///
+ private async Task EventsResult(JsonElement args, CancellationToken cancellationToken) {
+ var body = args.Deserialize(DshJsonContext.Default.DshEventsResultArgs);
+ if (body is null || string.IsNullOrEmpty(body.EventId) || body.Outcome is null) {
+ return Fail("events/result-rejected", $"$events/result 需要 {{clientId, eventId, outcome}} 三字段;收到 args={Describe(args)}");
+ }
+ await _services.Approvals.SettleAsync(body.ClientId, body.EventId, body.Outcome);
+ return Ok(new DshEventsResultValue());
+ }
+
+ ///
+ /// 权限预设目录 — 引擎 枚举为唯一数据源(TASK058 DTO 统一原则):
+ /// 枚举 [EnumValue] 值即前端词汇(plan/auto/ask/bypass/unattended),默认取引擎当前模式,未就绪回退 auto。
+ ///
+ private async Task PermissionPresetsCatalog(JsonElement args, CancellationToken cancellationToken) {
+ var current = _services.Engine is { } engine
+ ? await engine.GetCurrentPermissionModeAsync(cancellationToken)
+ : PermissionMode.Auto;
+ var options = Enum.GetValues()
+ .Select(mode => new DshPresetOption { Value = mode.ToValue(), Name = mode.ToValue() })
+ .ToList();
+ var currentName = current.ToValue();
+ return Ok(new DshPermissionPresetCatalog {
Options = options,
DefaultOptions = options,
- DefaultPreset = "workspace-write",
- };
+ DefaultPreset = options.Any(option => option.Value == currentName) ? currentName : PermissionMode.Auto.ToValue(),
+ });
}
private static DshRpcResult Ok(DshSessionList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionList));
@@ -326,6 +429,8 @@ private static DshPermissionPresetCatalog DefaultPermissionPresets() {
private static DshRpcResult Ok(DshPermissionPresetCatalog value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshPermissionPresetCatalog));
+ private static DshRpcResult Ok(DshEventsResultValue value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshEventsResultValue));
+
private static DshRpcResult Ok(DshSettingsDescribe value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSettingsDescribe));
private static DshRpcResult Ok(DshSkillList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSkillList));
@@ -344,8 +449,16 @@ private static DshPermissionPresetCatalog DefaultPermissionPresets() {
private static DshRpcResult Ok(DshPinnedResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshPinnedResult));
+ private static DshRpcResult Ok(DshSessionSelectModelResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionSelectModelResult));
+
+ private static DshRpcResult Ok(DshSessionForkResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionForkResult));
+
private static DshRpcResult Ok(List value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.ListDshProviderInfo));
+ private static DshRpcResult Ok(List value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.ListDshCommandInfo));
+
+ private static DshRpcResult Ok(DshCommandsExecuteResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshCommandsExecuteResult));
+
private static DshRpcResult Ok(Dictionary value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DictionaryStringDshCredentialState));
private static DshRpcResult OkEmptyArray() => Success(JsonSerializer.SerializeToElement(Array.Empty(), DshJsonContext.Default.JsonElementArray));
diff --git a/modules/apps/gui/web_host/DshRpcProtocol.cs b/modules/apps/gui/web_host/DshRpcProtocol.cs
index fb6ec2f12..e48d56b52 100644
--- a/modules/apps/gui/web_host/DshRpcProtocol.cs
+++ b/modules/apps/gui/web_host/DshRpcProtocol.cs
@@ -91,6 +91,98 @@ internal sealed class DshHostInfo {
public string Home { get; set; } = "";
}
+///
+/// $events 流上的 waterfall 帧(宿主→前端)。前端 dsh-api-gateway parseRemoteEventFrame 硬校验:
+/// 必须且只能含 type/event/eventId/agentId/request 五键;request 禁含 agent/signal(前端 dispatchWaterfall 自行注入)。
+///
+internal sealed class DshWaterfallFrame {
+ /// 固定为 waterfall
+ public string Type { get; set; } = "waterfall";
+ /// 事件名(如 approval/request)
+ public string Event { get; set; } = "";
+ /// 本次请求的关联 ID(回帖 $events/result 用)
+ public string EventId { get; set; } = "";
+ /// 目标 Agent/会话身份(前端按此路由到对应会话 scope 的监听器)
+ public string AgentId { get; set; } = "";
+ /// 请求载荷(事件专属结构;approval 为 toolName/reason/callId?)
+ public JsonElement Request { get; set; }
+}
+
+/// $events/result 回帖参数(前端→宿主,POST /api/$events/result)
+internal sealed class DshEventsResultArgs {
+ /// $events 就绪帧下发的 clientId
+ public string ClientId { get; set; } = "";
+ /// waterfall 帧的 eventId
+ public string EventId { get; set; } = "";
+ /// 监听器处理结果
+ public DshEventOutcome? Outcome { get; set; }
+}
+
+/// waterfall 监听器处理结果(kind=next 表示无人处理;result 携带决策值;rejected 表示监听器抛错)
+internal sealed class DshEventOutcome {
+ /// next / result / rejected
+ public string Kind { get; set; } = "";
+ /// 决策值(approval 域为 allowed-once / rejected)
+ public JsonElement? Value { get; set; }
+ /// rejected 时的错误信息
+ public DshEventErrorBody? Error { get; set; }
+}
+
+/// rejected 错误体(前端 projectRemoteEventRejection 投影)
+internal sealed class DshEventErrorBody {
+ /// 错误名
+ public string Name { get; set; } = "Error";
+ /// 错误消息
+ public string Message { get; set; } = "";
+ /// 稳定错误码(可选)
+ public string? Code { get; set; }
+}
+
+/// $events/result 回帖响应(前端只看 ok,无业务载荷)
+internal sealed class DshEventsResultValue;
+
+/// commands/list 条目(前端以 /{name} 组装命令行)
+internal sealed class DshCommandInfo {
+ /// 命令名(无 / 前缀)
+ public string Name { get; set; } = "";
+ /// 描述
+ public string Description { get; set; } = "";
+ /// 输入提示(可选)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DshCommandInputInfo? Input { get; set; }
+}
+
+/// 命令输入提示
+internal sealed class DshCommandInputInfo {
+ /// 参数提示(如 model-id)
+ public string Hint { get; set; } = "";
+}
+
+/// commands/execute 参数(位置参数 wire 平铺:agentId + line;attachments v1 不支持)
+internal sealed class DshCommandsExecuteArgs {
+ /// 目标会话(scope agent)
+ public string AgentId { get; set; } = "";
+ /// 命令行(如 /model deepseek-v4-pro)
+ public string? Line { get; set; }
+}
+
+/// commands/execute 响应 — commandId + 成败结果
+internal sealed class DshCommandsExecuteResult {
+ /// 命令标识
+ public string CommandId { get; set; } = "";
+ /// 执行结果
+ public DshCommandOutcome Result { get; set; } = new();
+}
+
+/// 命令执行结果体(success 带输出文本,error 带提示文本)
+internal sealed class DshCommandOutcome {
+ /// success / error
+ public string Kind { get; set; } = "success";
+ /// 输出或错误文本(可选)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Text { get; set; }
+}
+
/// workspace/follow 基线
internal sealed class DshWorkspaceBaseline {
/// 固定为 baseline
@@ -622,10 +714,68 @@ internal sealed class DshAssistantMessageData {
public int Step { get; set; }
/// 消息体
public DshWireMessage Message { get; set; } = new();
+ /// 本轮用量(引擎 Complete.Usage 投影,未报告时整键省略,TASK058 B4)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DshTurnUsage? Usage { get; set; }
/// 流式块(历史消息为空数组)
public List Stream { get; set; } = [];
}
+/// 本轮用量 — 前端 TurnUsagePanel 四桶(totalTokens = 四桶之和,对齐前端 tokenTotal 语义)
+internal sealed class DshTurnUsage {
+ /// 未缓存输入 token(prompt − cacheRead)
+ public int UncachedInputTokens { get; set; }
+ /// 输出 token
+ public int OutputTokens { get; set; }
+ /// 缓存读取 token
+ public int CacheReadTokens { get; set; }
+ /// 缓存写入 token
+ public int CacheWriteTokens { get; set; }
+ /// 总 token(四桶之和)
+ public int TotalTokens { get; set; }
+ /// 推理 token(可选,未报告时整键省略)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? ReasoningTokens { get; set; }
+}
+
+/// tool-workflow/run-start 载荷(子代理卡片创建,TASK058 B6)
+internal sealed class DshWorkflowRunStartData {
+ /// 运行标识(宿主按轮合成 {sessionId}:{turn}:agents)
+ public string RunId { get; set; } = "";
+ /// 运行名
+ public string Name { get; set; } = "";
+}
+
+/// tool-workflow/agent-start 载荷(成员加入)
+internal sealed class DshWorkflowAgentStartData {
+ /// 运行标识
+ public string RunId { get; set; } = "";
+ /// 轮内成员序号(start/end 配对键)
+ public int Seq { get; set; }
+ /// 显示名
+ public string Label { get; set; } = "";
+ /// 子代理会话 ID
+ public string ChildId { get; set; } = "";
+}
+
+/// tool-workflow/agent-end 载荷(成员定格;outcome 值域 completed/cancelled/failed)
+internal sealed class DshWorkflowAgentEndData {
+ /// 运行标识
+ public string RunId { get; set; } = "";
+ /// 轮内成员序号
+ public int Seq { get; set; }
+ /// 结果
+ public string Outcome { get; set; } = "";
+}
+
+/// tool-workflow/run-end 载荷(stopReason 值域 completed/cancelled/error)
+internal sealed class DshWorkflowRunEndData {
+ /// 运行标识
+ public string RunId { get; set; } = "";
+ /// 停止原因
+ public string StopReason { get; set; } = "";
+}
+
/// 助手流基线(revision=0 表示无进行中的尝试)
internal sealed class DshAssistantStreamBaseline {
/// 流版本
@@ -665,6 +815,40 @@ internal sealed class DshModelChoice {
public string? ReasoningEffort { get; set; }
}
+/// session/selectModel 参数
+internal sealed class DshSessionSelectModelArgs {
+ /// 会话 ID
+ public string SessionId { get; set; } = "";
+ /// 供应商
+ public string Provider { get; set; } = "";
+ /// 模型 ID
+ public string Model { get; set; } = "";
+ /// 推理强度(low/medium/high/max,可选)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ReasoningEffort { get; set; }
+}
+
+/// session/selectModel 响应 — 回显实际选中的模型
+internal sealed class DshSessionSelectModelResult {
+ /// 选中的模型
+ public DshModelChoice Selected { get; set; } = new();
+}
+
+/// session/fork 参数
+internal sealed class DshSessionForkArgs {
+ /// 源会话 ID
+ public string SessionId { get; set; } = "";
+ /// 分叉点事件序号(可选;v1 全量复制,深化项)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public long? AtSeq { get; set; }
+}
+
+/// session/fork 响应 — 新会话 ID
+internal sealed class DshSessionForkResult {
+ /// 新会话 ID
+ public string SessionId { get; set; } = "";
+}
+
/// 供应商模型分组
internal sealed class DshModelGroup {
/// 供应商 ID
@@ -998,15 +1182,40 @@ internal sealed class DshStreamErrorBody {
[JsonSerializable(typeof(DshTurnEndData))]
[JsonSerializable(typeof(DshTurnEndReason))]
[JsonSerializable(typeof(DshAssistantMessageData))]
+[JsonSerializable(typeof(DshTurnUsage))]
+[JsonSerializable(typeof(DshWorkflowRunStartData))]
+[JsonSerializable(typeof(DshWorkflowAgentStartData))]
+[JsonSerializable(typeof(DshWorkflowAgentEndData))]
+[JsonSerializable(typeof(DshWorkflowRunEndData))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(DshAssistantStreamBaseline))]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(DshModelCatalog))]
+[JsonSerializable(typeof(DshSessionSelectModelArgs))]
+[JsonSerializable(typeof(DshSessionSelectModelResult))]
+[JsonSerializable(typeof(DshSessionForkArgs))]
+[JsonSerializable(typeof(DshSessionForkResult))]
+[JsonSerializable(typeof(DshRequestEnvelope))]
+[JsonSerializable(typeof(DshRequestEnvelope))]
[JsonSerializable(typeof(DshAgentPresetList))]
[JsonSerializable(typeof(DshPermissionPresetCatalog))]
+[JsonSerializable(typeof(DshWaterfallFrame))]
+[JsonSerializable(typeof(DshApprovalRequestBody))]
+[JsonSerializable(typeof(DshEventsResultArgs))]
+[JsonSerializable(typeof(DshEventOutcome))]
+[JsonSerializable(typeof(DshEventsResultValue))]
+[JsonSerializable(typeof(DshEventErrorBody))]
+[JsonSerializable(typeof(DshCommandInfo))]
+[JsonSerializable(typeof(DshCommandInputInfo))]
+[JsonSerializable(typeof(DshCommandsExecuteArgs))]
+[JsonSerializable(typeof(DshCommandsExecuteResult))]
+[JsonSerializable(typeof(DshCommandOutcome))]
+[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(DshSettingsDescribe))]
[JsonSerializable(typeof(DshSettingsNamespaceView))]
[JsonSerializable(typeof(DshSettingsMutateArgs))]
+[JsonSerializable(typeof(DshSettingsDocument))]
+[JsonSerializable(typeof(DshSettingsNamespaceDocument))]
[JsonSerializable(typeof(DshSchemaEnvelope))]
[JsonSerializable(typeof(DshSkillList))]
[JsonSerializable(typeof(DshMessageFeedbackListResult))]
diff --git a/modules/apps/gui/web_host/DshSessionStore.cs b/modules/apps/gui/web_host/DshSessionStore.cs
index 5b3b7be98..23049d15e 100644
--- a/modules/apps/gui/web_host/DshSessionStore.cs
+++ b/modules/apps/gui/web_host/DshSessionStore.cs
@@ -244,6 +244,38 @@ internal async Task> Projection
return (resolvedId, DefaultAgentPreset);
}
+ ///
+ /// 分叉会话(TASK058 B7):复制源会话 meta + 转录 + journal 事件到新 ID(v1 全量复制,atSeq 截断为深化项)。
+ /// 源会话不受影响;新会话立即可被列表/打开/续聊。
+ ///
+ /// 源会话
+ /// 宿主事件日志(journal 事件随分叉复制)
+ /// 取消令牌
+ /// 新会话 ID;源会话不存在或存储未就绪返回 null
+ internal async Task ForkAsync(string sourceSessionId, DshSessionJournal journal, CancellationToken cancellationToken) {
+ if (_transcripts is not { } transcripts) {
+ return null;
+ }
+ var sourceInfo = await transcripts.GetSessionInfoAsync(sourceSessionId, cancellationToken);
+ if (sourceInfo is null) {
+ return null;
+ }
+ var newId = Guid.NewGuid().ToString("N");
+ await transcripts.EnsureSessionAsync(newId, sourceInfo with {
+ Id = newId,
+ CreatedAt = DateTime.UtcNow,
+ }, cancellationToken);
+ var entries = await transcripts.LoadTranscriptAsync(sourceSessionId, cancellationToken);
+ if (entries.Count > 0) {
+ await transcripts.AppendEntriesAsync(newId, entries, cancellationToken);
+ }
+ var events = await journal.LoadAsync(sourceSessionId, cancellationToken);
+ foreach (var journalEvent in events) {
+ await journal.AppendAsync(newId, journalEvent, cancellationToken);
+ }
+ return newId;
+ }
+
/// 投影键值(标题 / 代理预设 / 模型选择 / 列表元数据)
private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList entries) {
var lastPrompt = entries.LastOrDefault(entry => entry.Type is null && !entry.IsSidechain && entry.Role == "user");
diff --git a/modules/apps/gui/web_host/DshSettingsStore.cs b/modules/apps/gui/web_host/DshSettingsStore.cs
index ede4c71a0..e21c8ec8b 100644
--- a/modules/apps/gui/web_host/DshSettingsStore.cs
+++ b/modules/apps/gui/web_host/DshSettingsStore.cs
@@ -1,61 +1,94 @@
namespace JoinCode.Gui.WebHost;
///
-/// DSH 设置命名空间存储(ADR 0143)。前端把「预览版说明」等确认状态写进
-/// ui-settings-general,并立刻用 settings/describe 读回来判定成功,
-/// 因此宿主必须真的落库并维护 revision。
+/// DSH 设置命名空间存储(ADR 0143,TASK058 B3 持久化改造)。前端把「预览版说明」等确认状态写进
+/// ui-settings-general,权限预设行写 permission 命名空间的 defaultPreset。
/// 值以「段路径 → JsonElement」扁平存储,读回时用 还原成嵌套对象树
/// (不手写 JSON 字符串,遵守 ADR 0133)。
-/// 当前为进程内存储(M2 再接入自有引擎的设置持久化与真实 schema)。
+/// 持久化:~/.jcc/dsh-settings.json(RelaxedJsonSerializer + JsonContext DTO,重启不丢,消除进程内假数据);
+/// permission/defaultPreset 的值域以引擎 枚举 [EnumValue] 词汇为唯一数据源,
+/// 写入成功即触发 (宿主路由到引擎 SetPermissionModeAsync)。
///
internal sealed class DshSettingsStore {
/// 前端「预览版说明」确认所在的命名空间
private const string WelcomeNamespace = "ui-settings-general";
+ /// 权限预设命名空间(前端 ui-permission-presets 写 defaultPreset)
+ private const string PermissionNamespace = "permission";
+
+ private readonly IFileSystem _fs;
+ private readonly string _filePath;
private readonly Lock Gate = new();
private readonly Dictionary States = new(StringComparer.Ordinal) {
[WelcomeNamespace] = new(),
};
private readonly JsonElement JsonNull = JsonSerializer.SerializeToElement