Add managed multi-agent collaboration runtime

This commit is contained in:
Haitao Pan 2026-03-19 12:33:50 +08:00
parent f40f12f935
commit 8067916b5b
15 changed files with 4052 additions and 90 deletions

View File

@ -2,44 +2,50 @@
## 概述
XWorkmate 当前有三组独立但可组合的集成面
XWorkmate 现阶段的集成基线已经从“单一 Codex bridge”升级为“统一发现与分发中心”。App 负责发现、托管和分发三类协作资产
1. **OpenClaw Gateway**
- 设备配对
- Agent 列表与聊天
- `cron.list` 只读任务视图
- `memory/sync` 同步能力
2. **AI Gateway**
- 统一模型入口
- 模型目录同步
- 给 Codex CLI 提供模型桥接
3. **Code Agent Runtime**
- 当前唯一可交付路径是外部 `Codex CLI`
- 内置 Rust FFI 仍是 future work
- 所有 runtime 都挂在 `XWorkmate App node` 后面,而不是直接挂到 Gateway
1. `skills`
2. `MCP server list`
3. `AI Gateway` 默认注入
## 当前真实链路
运行时上XWorkmate 不再把 CLI 视为孤立工具,而是通过本地 broker 与编排层统一驱动 `OpenClaw / Codex / Claude / Gemini / OpenCode`
## 当前架构基线
```mermaid
flowchart LR
X["XWorkmate App Node"] -->|JSON-RPC over stdio| C["Codex CLI"]
C -->|HTTP| A["AI Gateway"]
X -->|WebSocket RPC| G["OpenClaw Gateway"]
X -->|agent/register + chat.send metadata| G
X["XWorkmate App"] --> D["Discovery / Distribution Catalog"]
X --> B["MultiAgentBroker<br/>WebSocket JSON-RPC"]
X --> G["OpenClaw Gateway / Host"]
B --> O["MultiAgentOrchestrator"]
O --> C["Codex CLI"]
O --> L["Claude CLI"]
O --> M["Gemini CLI"]
O --> P["OpenCode CLI"]
C --> A["AI Gateway"]
L --> A
M --> A
P --> A
A --> OL["Ollama / Upstream Model Endpoints"]
```
关键点:
- `Codex CLI` 不直接连接 `OpenClaw Gateway`
- `XWorkmate App` 是唯一的 cooperative node
- 本地内置/扩展/外部 CLI 都是 node 后端 runtime
- AI Gateway 与 OpenClaw Gateway 是两套不同职责的集成面
- `XWorkmate App` 是唯一的 discovery / distribution center。
- `MultiAgentBroker` 是多 CLI 协作的本地运行时入口。
- `OpenClaw` 既是现有 Gateway 集成面,也是可被托管发现的宿主控制面。
- `AI Gateway` 的语义是“XWorkmate 协作运行默认 provider”不是用户全局 provider 替换器。
## 1. OpenClaw Gateway
## 1. OpenClaw Gateway / Host
用途:运行时协同、响应返回和设备信任边界。
用途:
当前已用到的能力:
- 运行时协同
- 设备与信任边界
- Agent / Session / Chat 通道
- 宿主控制面发现
已使用能力:
- `health`
- `status`
@ -51,59 +57,87 @@ flowchart LR
- `agent/register`
- `memory/sync`
当前产品边界
新的定位
- Scheduled Tasks 只读展示 `cron.list`
- Memory 只暴露同步语义,不提供 CRUD UI
- 远程模式必须保持 TLS 显式配置
- Gateway 接收到的是来自 `XWorkmate App node` 的交互和 metadata不是 CLI 直连 RPC
- 继续作为 Gateway RPC 面存在。
- 额外纳入“可挂载目标”集合。
- 发现 `agents / skills / plugins` 状态,但不覆盖用户现有默认 agent。
## 2. AI Gateway
用途:为外部 Codex CLI 提供统一模型桥接。
用途:
当前链路:
- 统一模型入口
- 作为 XWorkmate 协作运行的默认模型路由
- 为外部 CLI 提供 launch-scoped 或托管 provider 注入
1. 用户在设置中配置 AI Gateway URL、模型和 API Key。
2. `CodexConfigBridge` 把配置写入 `~/.codex/config.toml`
3. 外部 `codex app-server` 通过该配置把推理请求转发到 AI Gateway。
边界:
这部分不负责:
- 不负责设备配对
- 不负责 session / agent 生命周期
- 不替换用户现有默认 provider / model
- 设备配对
- 任务调度
- Agent 注册
当前策略:
## 3. Code Agent Runtime
- `Codex` 可以追加 `xworkmate` provider 托管块
- `Claude / Gemini / OpenCode` 优先采用 launch-scoped 注入
- Gateway 不可用时允许回退到 CLI 原有配置
### 当前可用路径
## 3. Multi-Agent Runtime
- `RuntimeCoordinator`
- `CodexRuntime.startStdio()`
- `ExternalCodeAgentProvider`
- `CodeAgentNodeOrchestrator`
### 编排层
已支持
`MultiAgentOrchestrator` 负责:
- 显式启用 / 停用 bridge
- 手动覆盖 Codex 二进制路径
- Gateway 已连接时注册为 `code-agent-bridge`
- `chat.send` 携带 node / provider / bridge dispatch metadata
- 为未来其他外部 CLI 预留统一 provider contract
- Architect 任务分析
- Engineer 实现
- Tester / Doc 审阅
- 迭代评分与回退
### 当前不可用路径
### Broker 层
Built-in Codex / Rust FFI 仍不可用。
`MultiAgentBroker` 负责:
现状:
- 本地 `WebSocket JSON-RPC`
- run lifecycle
- worker CLI 启动
- selected skills / MCP / Gateway 上下文注入
- 结构化事件流回写当前会话
- `builtIn` 只保留配置位
- UI 只显示 `Experimental / Unavailable`
- Rust FFI 核心 TODO 尚未补完
### UI 接线
## 4. 外部 Provider 预留
- Assistant 继续复用现有 composer、附件、当前会话
- Settings 继续复用现有 Multi-Agent 区块
- 不新增独立任务页面
当前统一 contract
## 4. 发现与分发
XWorkmate 统一维护两类状态:
- `managed`
- 由 App 创建与维护的托管项
- `external`
- 外部已有配置或 CLI 自带配置
统一规则:
- 只更新 XWorkmate 托管项
- 不删除外部已有项
- 启动时与保存设置后自动 reconcile
## 5. 挂载入口矩阵
| 目标 | Skills 挂载入口 | MCP 挂载入口 | AI Gateway 挂载入口 |
| --- | --- | --- | --- |
| OpenClaw | 发现 `skills / plugins / agents`broker 注入上下文 | 不作为 MCP 主挂载点 | XWorkmate 协作路径默认 route |
| Codex | `AGENTS.md` / skill 上下文 / broker 注入 | `~/.codex/config.toml` 托管块 | `model_providers.xworkmate`,不替换用户默认 |
| Claude | broker 注入 | `claude mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入 |
| Gemini | broker 注入,后续可扩展 `extensions` | `gemini mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入 |
| OpenCode | broker 注入,后续可扩展 agent preset | `~/.opencode/config.toml` 托管块 | 启动参数或托管 preset 注入 |
## 6. 外部 Provider 与执行路径
保留现有统一 contract
- `ExternalCodeAgentProvider.id`
- `name`
@ -112,30 +146,30 @@ Built-in Codex / Rust FFI 仍不可用。
- `capabilities`
- `CodeAgentNodeOrchestrator.buildGatewayDispatch()`
当前 active provider
现状
- `codex`
- `codex` 仍是当前最完整 provider
- 其他 CLI 通过 `CliMountAdapter` 与 broker 接入
- 多 provider 调度 UI 不是当前交付目标
暂不实现:
- provider 切换 UI
- capability discovery UI
- 多 provider 调度策略
## 5. 安全边界
## 7. 安全边界
- `.env` 仅用于开发预填充,不自动连接,不作为持久化真值源
- AI Gateway API Key Gateway 凭证继续走 secure storage
- 外部 Codex CLI 路径仅保存文件路径,不保存 secret
- `chat.send` 的 node metadata 仅上传 node/provider 状态,不上传 Gateway secret 或本地 CLI 绝对路径
- AI Gateway API Key 与 Gateway 凭证继续走 secure storage
- 新增协作路径不得把 secret 写入 `SharedPreferences`
- Launch-scoped 注入优先于全局配置改写
- 远程 Gateway 不允许静默降级为非 TLS
- 协作事件与 metadata 不上传本地 secret 或本机绝对路径
## 相关代码
- `lib/app/app_controller.dart`
- `lib/runtime/runtime_coordinator.dart`
- `lib/runtime/codex_runtime.dart`
- `lib/features/assistant/assistant_page.dart`
- `lib/features/settings/settings_page.dart`
- `lib/runtime/runtime_models.dart`
- `lib/runtime/multi_agent_orchestrator.dart`
- `lib/runtime/multi_agent_broker.dart`
- `lib/runtime/multi_agent_mounts.dart`
- `lib/runtime/codex_config_bridge.dart`
- `lib/runtime/code_agent_node_orchestrator.dart`
- `lib/runtime/agent_registry.dart`
- `lib/runtime/gateway_runtime.dart`
- `lib/runtime/opencode_config_bridge.dart`
- `lib/runtime/runtime_coordinator.dart`

View File

@ -0,0 +1,175 @@
# XWorkmate Multi-Agent 协作增强方案
## 背景与目标
XWorkmate 已具备 Assistant 工作台、Gateway 连接、AI Gateway 模型路由、Ollama 本地模型配置,以及外部 CLI 运行时基础。当前缺口不是再造一个新界面,而是把现有入口收敛成一条完整的多代理协作链路。
本次增强的目标有三点:
1. 复用现有 Assistant composer、附件、skill picker 与当前会话。
2. 让 App 成为 `skills`、`MCP server list`、`AI Gateway 默认注入` 的统一发现与分发中心。
3. 在不破坏现有主布局的前提下,引入 `MultiAgentBroker` 与 mount adapter`OpenClaw / Codex / Claude / Gemini / OpenCode` 纳入统一协作运行时。
## 本次范围
- 文档先行,固化术语、架构和验收标准。
- 在 `SettingsSnapshot.multiAgent` 中正式持久化多代理配置。
- 复用现有 Settings 页面中的 Multi-Agent 区块,不新增页面。
- 复用现有 Assistant 输入与当前会话,不新增独立任务对话框。
- 新增本地 `WebSocket JSON-RPC` broker驱动协作 run 与事件回写。
- 新增 mount/discovery 适配层,最小支持 `Codex / Claude / Gemini / OpenCode / OpenClaw`
## 非范围
- 首版不要求每个 CLI 都完成原生 skills 安装。
- 首版不要求 App 管理用户全部 MCP 项,只管理 `xworkmate/*` 托管项。
- 首版不替换用户原有 provider、默认模型或默认 agent。
- 首版不直接合并外部仓库实现,只保留适配接口与挂载接线点。
## 目标链路
```mermaid
flowchart LR
U["Assistant Composer"] --> X["XWorkmate App"]
X --> B["MultiAgentBroker<br/>WebSocket JSON-RPC"]
X --> D["Discovery / Distribution Catalog"]
D --> M["Managed Skills / MCP / Gateway Injection"]
B --> O["MultiAgentOrchestrator"]
O --> G["Gemini / Claude / Codex / OpenCode"]
X --> OC["OpenClaw Gateway / Host"]
G --> A["AI Gateway / Ollama"]
```
## 配置与数据模型
`SettingsSnapshot.multiAgent` 是多代理配置的唯一持久化真值源,至少包含:
- 协作启用状态
- `architect / engineer / tester` 三角色配置
- 自动同步开关
- AI Gateway 注入策略
- 托管 `skills`
- 托管 `MCP server list`
- 已发现的挂载目标状态
### 关键模型
- `MultiAgentConfig`
- `ManagedSkillEntry`
- `ManagedMcpServerEntry`
- `ManagedMountTargetState`
- `AiGatewayInjectionPolicy`
- `MultiAgentRunEvent`
### 状态分层
- `managed`
- 由 XWorkmate 创建、更新、回收的托管项
- `external`
- 来自 CLI 自身、本地文件或现有环境的补充发现项
XWorkmate 只维护 `managed`,绝不覆盖 `external`
## 挂载入口矩阵
| 目标 | Skills | MCP Server List | AI Gateway 默认注入 |
| --- | --- | --- | --- |
| OpenClaw | 发现 `skills/plugins/agents`,协作时由 broker 注入上下文 | 不作为 MCP 主目标 | 仅为 XWorkmate 托管协作路径提供默认 provider / route 语义 |
| Codex | `AGENTS.md` / skill 上下文注入 | `~/.codex/config.toml` 托管块 + `codex mcp` 兼容 | 仅新增 `xworkmate` provider不替换用户默认 |
| Claude | broker 注入 | `claude mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入,不写全局默认 |
| Gemini | broker 注入,后续可扩展 `extensions` | `gemini mcp list/add/remove` 发现与兼容 | 启动参数 / 环境注入,不改用户默认模型 |
| OpenCode | broker 注入,后续可扩展 agent preset | `~/.opencode/config.toml` 托管块 | 托管 preset 或启动参数注入,不替换用户主 agent |
## 同步与 reconcile 策略
统一规则:
- 启动时自动发现。
- 保存 Multi-Agent 设置后重新 reconcile。
- 只管理 `xworkmate/*` 托管项。
- 外部已有项只发现、不删除。
- 配置写入以“托管块”或“增量追加”为准,不整体重写。
### AI Gateway 默认注入
语义是:
- 只对 XWorkmate 发起的协作 run 生效。
- 优先作为默认 provider / model route 使用。
- 失败时允许回退到原 CLI 路由。
- 不替换用户全局默认 provider / model。
## 运行时架构
`MultiAgentOrchestrator` 保留为编排层,负责:
- Architect 任务分析
- Engineer 实现
- Tester / Doc 审阅
- 迭代与评分策略
`MultiAgentBroker` 作为 App 与 CLI worker 之间的本地 broker负责
- `WebSocket JSON-RPC` run lifecycle
- worker CLI 启动
- selected skills / 托管 MCP / AI Gateway 上下文注入
- 结构化事件流
- 失败、取消与回退状态返回
## UI 接线原则
### Assistant
- 继续复用现有输入框、附件、技能选择、当前会话。
- 协作模式开启时,`_submitPrompt()` 改走 broker。
- 协作事件流写回当前 session。
### Settings
- 继续复用现有 Multi-Agent 区块。
- 最小增量展示:
- 协作启用状态
- 三角色 CLI / 模型
- 自动同步
- AI Gateway 注入策略
- mount target 发现与同步状态
## 安全约束
- `.env` 仍然只用于开发预填充,不作为持久化真值源。
- Gateway secret 与 AI Gateway API Key 继续走 secure storage。
- 新的协作路径不得把 secret 写入 `SharedPreferences`
- Launch-scoped 注入优先于持久配置改写。
- 远程 Gateway 不得静默降级为非 TLS。
## 验收标准
### 配置
- `SettingsSnapshot.multiAgent` 能正确保存与加载。
- secrets 不进入普通 settings JSON。
### 分发
- `~/.codex/config.toml``~/.opencode/config.toml` 只更新托管块。
- `Claude / Gemini` 的 discovery 与状态刷新不破坏现有配置。
- `OpenClaw` 不改用户现有默认 agent/provider。
### 运行时
- 协作模式开启时Assistant 走 broker 路径。
- 关闭时仍走原有单 Agent chat 链路。
- 阶段事件可持续写回当前 session。
- AI Gateway 不可用时有清晰回退路径。
### UI
- Assistant 主布局不变。
- Settings 只做增量信息扩展。
### 验证
- `flutter analyze`
- 相关单测
- 非破坏性托管配置验证
- 遵循 `docs/security/secure-development-rules.md`

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
@ -19,6 +20,9 @@ import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_broker.dart';
import '../runtime/multi_agent_mounts.dart';
import '../runtime/multi_agent_orchestrator.dart';
enum CodexCooperationState { notStarted, bridgeOnly, registered }
@ -62,6 +66,11 @@ class AppController extends ChangeNotifier {
_tasksController = DerivedTasksController();
_desktopPlatformService =
desktopPlatformService ?? createDesktopPlatformService();
_multiAgentMountManager = MultiAgentMountManager();
_multiAgentOrchestrator = MultiAgentOrchestrator(
config: _resolveMultiAgentConfig(_settingsController.snapshot),
);
_attachChildListeners();
unawaited(_initialize());
}
@ -83,6 +92,14 @@ class AppController extends ChangeNotifier {
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
late final DesktopPlatformService _desktopPlatformService;
late final MultiAgentMountManager _multiAgentMountManager;
late final MultiAgentOrchestrator _multiAgentOrchestrator;
MultiAgentBrokerServer? _multiAgentBrokerServer;
MultiAgentBrokerClient? _multiAgentBrokerClient;
final Map<String, List<GatewayChatMessage>> _localSessionMessages =
<String, List<GatewayChatMessage>>{};
bool _multiAgentRunPending = false;
int _localMessageCounter = 0;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
ThemeMode _themeMode = ThemeMode.light;
@ -116,6 +133,7 @@ class AppController extends ChangeNotifier {
SettingsController get settingsController => _settingsController;
GatewayAgentsController get agentsController => _agentsController;
GatewaySessionsController get sessionsController => _sessionsController;
MultiAgentOrchestrator get multiAgentOrchestrator => _multiAgentOrchestrator;
GatewayChatController get chatController => _chatController;
InstancesController get instancesController => _instancesController;
SkillsController get skillsController => _skillsController;
@ -170,12 +188,142 @@ class AppController extends ChangeNotifier {
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
configuredCodeAgentRuntimeMode;
CodexCooperationState get codexCooperationState => _codexCooperationState;
bool get isMultiAgentRunPending => _multiAgentRunPending;
bool _desktopPlatformBusy = false;
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> saveMultiAgentConfig(MultiAgentConfig config) async {
final resolved = _resolveMultiAgentConfig(
settings.copyWith(multiAgent: config),
);
await saveSettings(
settings.copyWith(multiAgent: resolved),
refreshAfterSave: false,
);
await refreshMultiAgentMounts(sync: resolved.autoSync);
}
Future<void> refreshMultiAgentMounts({bool sync = false}) async {
final resolved = _resolveMultiAgentConfig(settings);
final reconciled = await _multiAgentMountManager.reconcile(
config: sync ? resolved : resolved.copyWith(autoSync: false),
aiGatewayUrl: aiGatewayUrl,
);
if (jsonEncode(reconciled.toJson()) !=
jsonEncode(settings.multiAgent.toJson())) {
await _settingsController.saveSnapshot(
settings.copyWith(multiAgent: reconciled),
);
}
_multiAgentOrchestrator.updateConfig(reconciled);
_notifyIfActive();
}
Future<void> runMultiAgentCollaboration({
required String rawPrompt,
required String composedPrompt,
required List<CollaborationAttachment> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = currentSessionKey.trim().isEmpty
? 'main'
: currentSessionKey;
final client = await _ensureMultiAgentBrokerClient();
final aiGatewayApiKey = await loadAiGatewayApiKey();
_multiAgentRunPending = true;
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'user',
text: rawPrompt,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
_recomputeTasks();
try {
await for (final event in client.runTask(
taskPrompt: composedPrompt,
workingDirectory:
_resolveCodexWorkingDirectory() ?? Directory.current.path,
attachments: attachments,
selectedSkills: selectedSkillLabels,
aiGatewayBaseUrl: aiGatewayUrl,
aiGatewayApiKey: aiGatewayApiKey,
)) {
if (event.type == 'result') {
final success = event.data['success'] == true;
final finalScore = event.data['finalScore'];
final iterations = event.data['iterations'];
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: success
? appText(
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
)
: appText(
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: !success,
),
);
continue;
}
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: event.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: event.title,
stopReason: null,
pending: event.pending,
error: event.error,
),
);
}
} catch (error) {
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: error.toString(),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} finally {
_multiAgentRunPending = false;
_recomputeTasks();
_notifyIfActive();
}
}
Future<void> openOnlineWorkspace() async {
const url = 'https://www.svc.plus/Xworkmate';
try {
@ -256,6 +404,11 @@ class AppController extends ChangeNotifier {
List<GatewayChatMessage> get chatMessages {
final items = List<GatewayChatMessage>.from(_chatController.messages);
final localItems =
_localSessionMessages[_sessionsController.currentSessionKey];
if (localItems != null && localItems.isNotEmpty) {
items.addAll(localItems);
}
final streaming = _chatController.streamingAssistantText?.trim() ?? '';
if (streaming.isNotEmpty) {
items.add(
@ -670,9 +823,12 @@ class AppController extends ChangeNotifier {
bool refreshAfterSave = true,
}) async {
final current = settings;
final sanitized = _sanitizeCodeAgentSettings(snapshot);
final sanitized = _sanitizeMultiAgentSettings(
_sanitizeCodeAgentSettings(snapshot),
);
setActiveAppLanguage(sanitized.appLanguage);
await _settingsController.saveSnapshot(sanitized);
_multiAgentOrchestrator.updateConfig(sanitized.multiAgent);
_agentsController.restoreSelection(sanitized.gateway.selectedAgentId);
_modelsController.restoreFromSettings(sanitized.aiGateway);
if (current.codexCliPath != sanitized.codexCliPath ||
@ -689,6 +845,7 @@ class AppController extends ChangeNotifier {
if (refreshAfterSave) {
_recomputeTasks();
}
unawaited(refreshMultiAgentMounts(sync: sanitized.multiAgent.autoSync));
notifyListeners();
}
@ -897,6 +1054,7 @@ class AppController extends ChangeNotifier {
_tasksController.dispose();
_store.dispose();
_desktopPlatformService.dispose();
unawaited(_multiAgentBrokerServer?.stop() ?? Future<void>.value());
super.dispose();
}
@ -920,8 +1078,8 @@ class AppController extends ChangeNotifier {
return;
}
}
final normalized = _sanitizeCodeAgentSettings(
_settingsController.snapshot,
final normalized = _sanitizeMultiAgentSettings(
_sanitizeCodeAgentSettings(_settingsController.snapshot),
);
if (normalized.toJsonString() !=
_settingsController.snapshot.toJsonString()) {
@ -931,6 +1089,7 @@ class AppController extends ChangeNotifier {
}
}
_modelsController.restoreFromSettings(settings.aiGateway);
_multiAgentOrchestrator.updateConfig(settings.multiAgent);
setActiveAppLanguage(settings.appLanguage);
await _desktopPlatformService.initialize(settings.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
@ -958,6 +1117,7 @@ class AppController extends ChangeNotifier {
// Keep the shell usable when auto-connect fails.
}
}
await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync);
} catch (error) {
if (_disposed) {
return;
@ -1017,6 +1177,71 @@ class AppController extends ChangeNotifier {
}
}
SettingsSnapshot _sanitizeMultiAgentSettings(SettingsSnapshot snapshot) {
final resolved = _resolveMultiAgentConfig(snapshot);
if (jsonEncode(snapshot.multiAgent.toJson()) ==
jsonEncode(resolved.toJson())) {
return snapshot;
}
return snapshot.copyWith(multiAgent: resolved);
}
MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) {
final defaults = MultiAgentConfig.defaults();
final current = snapshot.multiAgent;
final ollamaEndpoint = snapshot.ollamaLocal.endpoint.trim().isEmpty
? current.ollamaEndpoint
: snapshot.ollamaLocal.endpoint.trim();
final engineerModel = current.engineer.model.trim().isNotEmpty
? current.engineer.model.trim()
: snapshot.ollamaLocal.defaultModel.trim().isNotEmpty
? snapshot.ollamaLocal.defaultModel.trim()
: defaults.engineer.model;
final architectModel = current.architect.model.trim().isNotEmpty
? current.architect.model.trim()
: defaults.architect.model;
final testerModel = current.tester.model.trim().isNotEmpty
? current.tester.model.trim()
: defaults.tester.model;
return current.copyWith(
ollamaEndpoint: ollamaEndpoint,
architect: current.architect.copyWith(model: architectModel),
engineer: current.engineer.copyWith(model: engineerModel),
tester: current.tester.copyWith(model: testerModel),
mountTargets: current.mountTargets.isEmpty
? MultiAgentConfig.defaults().mountTargets
: current.mountTargets,
);
}
Future<MultiAgentBrokerClient> _ensureMultiAgentBrokerClient() async {
_multiAgentBrokerServer ??= MultiAgentBrokerServer(_multiAgentOrchestrator);
await _multiAgentBrokerServer!.start();
final uri = _multiAgentBrokerServer!.wsUri;
if (uri == null) {
throw StateError('Multi-agent broker is unavailable');
}
_multiAgentBrokerClient = MultiAgentBrokerClient(uri);
return _multiAgentBrokerClient!;
}
void _appendLocalSessionMessage(
String sessionKey,
GatewayChatMessage message,
) {
final key = sessionKey.trim().isEmpty ? 'main' : sessionKey.trim();
final next = List<GatewayChatMessage>.from(
_localSessionMessages[key] ?? const <GatewayChatMessage>[],
)..add(message);
_localSessionMessages[key] = next;
_notifyIfActive();
}
String _nextLocalMessageId() {
_localMessageCounter += 1;
return 'local-${DateTime.now().microsecondsSinceEpoch}-$_localMessageCounter';
}
SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) {
_codexRuntimeWarning =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
@ -1179,7 +1404,7 @@ class AppController extends ChangeNotifier {
sessions: _sessionsController.sessions,
cronJobs: _cronJobsController.items,
currentSessionKey: _sessionsController.currentSessionKey,
hasPendingRun: _chatController.hasPendingRun,
hasPendingRun: _chatController.hasPendingRun || _multiAgentRunPending,
activeAgentName: _agentsController.activeAgentName,
);
}
@ -1197,6 +1422,7 @@ class AppController extends ChangeNotifier {
_cronJobsController.addListener(_relayChildChange);
_devicesController.addListener(_relayChildChange);
_tasksController.addListener(_relayChildChange);
_multiAgentOrchestrator.addListener(_relayChildChange);
}
void _detachChildListeners() {
@ -1212,6 +1438,7 @@ class AppController extends ChangeNotifier {
_cronJobsController.removeListener(_relayChildChange);
_devicesController.removeListener(_relayChildChange);
_tasksController.removeListener(_relayChildChange);
_multiAgentOrchestrator.removeListener(_relayChildChange);
}
void _relayChildChange() {

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@ -8,6 +9,7 @@ import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
@ -574,12 +576,30 @@ class _AssistantPageState extends State<AssistantPage> {
);
});
final attachmentPayloads = await _buildAttachmentPayloads(_attachments);
await controller.sendChatMessage(
prompt,
thinking: _thinkingLabel,
attachments: attachmentPayloads,
);
if (controller.settings.multiAgent.enabled) {
final collaborationAttachments = _attachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false);
await controller.runMultiAgentCollaboration(
rawPrompt: rawPrompt,
composedPrompt: prompt,
attachments: collaborationAttachments,
selectedSkillLabels: selectedSkillLabels,
);
} else {
final attachmentPayloads = await _buildAttachmentPayloads(_attachments);
await controller.sendChatMessage(
prompt,
thinking: _thinkingLabel,
attachments: attachmentPayloads,
);
}
if (!mounted) {
return;
@ -2084,6 +2104,39 @@ class _ComposerBar extends StatelessWidget {
),
),
),
const SizedBox(width: 4),
Tooltip(
message: appText(
'多 Agent 协作模式Architect → Engineer → Tester',
'Multi-Agent Collaboration Mode (Architect → Engineer → Tester)',
),
child: AnimatedBuilder(
animation: controller.multiAgentOrchestrator,
builder: (context, _) {
final collab = controller.multiAgentOrchestrator;
final enabled = collab.config.enabled;
return IconButton(
key: const Key('assistant-collaboration-toggle'),
icon: Icon(
enabled
? Icons.auto_awesome
: Icons.auto_awesome_outlined,
size: 20,
color: enabled ? Colors.orange : null,
),
onPressed:
collab.isRunning || controller.isMultiAgentRunPending
? null
: () => unawaited(
controller.saveMultiAgentConfig(
collab.config.copyWith(enabled: !enabled),
),
),
splashRadius: 18,
);
},
),
),
],
),
const SizedBox(height: 8),

View File

@ -137,6 +137,11 @@ class _SettingsPageState extends State<SettingsPage> {
controller,
settings,
),
SettingsTab.agents => _buildAgents(
context,
controller,
settings,
),
SettingsTab.appearance => _buildAppearance(context, controller),
SettingsTab.diagnostics => _buildDiagnostics(
context,
@ -1261,6 +1266,397 @@ class _SettingsPageState extends State<SettingsPage> {
];
}
List<Widget> _buildAgents(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final orchestrator = controller.multiAgentOrchestrator;
final config = settings.multiAgent;
final theme = Theme.of(context);
final mountTargets = List<ManagedMountTargetState>.from(config.mountTargets)
..sort(
(left, right) =>
left.label.toLowerCase().compareTo(right.label.toLowerCase()),
);
final managedSkillCount = config.managedSkills
.where((item) => item.selected)
.length;
final managedMcpCount = config.managedMcpServers
.where((item) => item.enabled)
.length;
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('多 Agent 协作', 'Multi-Agent Collaboration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
appText(
'通过 Ollama 驱动多个 CLI 工具协同工作,实现 Architect → Engineer → Tester 的完整工作流。',
'Orchestrate multiple CLI agents via Ollama for Architect → Engineer → Tester workflows.',
),
style: theme.textTheme.bodyMedium,
),
],
),
),
_SwitchRow(
label: appText('启用协作模式', 'Enable Collaboration'),
value: config.enabled,
onChanged: (value) => _saveMultiAgentConfig(
controller,
config.copyWith(enabled: value),
),
),
],
),
const SizedBox(height: 16),
_InfoRow(label: 'Ollama', value: config.ollamaEndpoint),
_InfoRow(
label: appText('超时时间', 'Timeout'),
value: '${config.timeoutSeconds}s',
),
_InfoRow(
label: appText('运行状态', 'Runtime'),
value: orchestrator.isRunning
? appText('协作执行中', 'Collaboration running')
: config.enabled
? appText('已启用', 'Enabled')
: appText('已停用', 'Disabled'),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('角色配置', 'Role Configuration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
_AgentRoleCard(
title: '🎨 ${appText('Architect调度者', 'Architect (Scheduler)')}',
description: appText(
'负责任务分解、流程编排、宏观设计',
'Task decomposition, workflow orchestration, macro design',
),
cliTool: config.architect.cliTool,
model: config.architect.model,
enabled: config.architect.enabled,
cliOptions: const ['gemini', 'claude', 'codex', 'opencode'],
modelOptions: const ['gemini-2.0-flash', 'gemini-2.5-pro'],
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(cliTool: tool),
),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(model: model),
),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(enabled: enabled),
),
),
),
const SizedBox(height: 12),
_AgentRoleCard(
title: '🔧 ${appText('Engineer工程师', 'Engineer (Developer)')}',
description: appText(
'负责代码实现、重构、调试',
'Code implementation, refactoring, debugging',
),
cliTool: config.engineer.cliTool,
model: config.engineer.model,
enabled: config.engineer.enabled,
cliOptions: const ['claude', 'codex', 'opencode'],
modelOptions: _getLocalModelOptions(settings),
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(cliTool: tool),
),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(model: model),
),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(enabled: enabled),
),
),
),
const SizedBox(height: 12),
_AgentRoleCard(
title: '🔍 ${appText('Tester/Doc评审', 'Tester/Doc (Reviewer)')}',
description: appText(
'负责测试用例生成、代码审阅、文档撰写',
'Test generation, code review, documentation',
),
cliTool: config.tester.cliTool,
model: config.tester.model,
enabled: config.tester.enabled,
cliOptions: const ['codex', 'claude', 'opencode'],
modelOptions: const [
'gpt-oss:20b',
'qwen2.5-coder:latest',
'glm-4.7-flash',
],
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(tester: config.tester.copyWith(cliTool: tool)),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(tester: config.tester.copyWith(model: model)),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
tester: config.tester.copyWith(enabled: enabled),
),
),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('审阅策略', 'Review Strategy'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _EditableField(
label: appText('最大迭代次数', 'Max Iterations'),
value: config.maxIterations.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed != null && parsed > 0) {
_saveMultiAgentConfig(
controller,
config.copyWith(maxIterations: parsed),
);
}
},
),
),
const SizedBox(width: 16),
Expanded(
child: _EditableField(
label: appText('最低达标分数', 'Min Acceptable Score'),
value: config.minAcceptableScore.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed != null && parsed >= 1 && parsed <= 10) {
_saveMultiAgentConfig(
controller,
config.copyWith(minAcceptableScore: parsed),
);
}
},
),
),
],
),
const SizedBox(height: 8),
Text(
appText(
'当 Tester 评分低于最低分数时,将进入迭代审阅循环。最多迭代指定次数。',
'When Tester score is below minimum, iteration loop runs until max iterations or score达标.',
),
style: theme.textTheme.bodySmall,
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('发现与分发', 'Discovery & Distribution'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
appText(
'App 作为统一发现与分发中心,维护托管 skills、MCP server list 和 AI Gateway 默认注入,但不会覆盖用户原有 CLI 配置。',
'The app acts as the discovery and distribution center for managed skills, MCP server lists, and AI Gateway defaults without overwriting existing CLI config.',
),
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () =>
controller.refreshMultiAgentMounts(sync: config.autoSync),
child: Text(appText('刷新挂载', 'Refresh Mounts')),
),
],
),
const SizedBox(height: 16),
_SwitchRow(
label: appText('自动同步托管配置', 'Auto-sync managed config'),
value: config.autoSync,
onChanged: (value) => _saveMultiAgentConfig(
controller,
config.copyWith(autoSync: value),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
key: ValueKey(
'multi-agent-injection-${config.aiGatewayInjectionPolicy.name}',
),
initialValue: config.aiGatewayInjectionPolicy.name,
decoration: InputDecoration(
labelText: appText('AI Gateway 注入策略', 'AI Gateway Injection'),
),
items: AiGatewayInjectionPolicy.values
.map(
(policy) => DropdownMenuItem<String>(
value: policy.name,
child: Text(policy.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
_saveMultiAgentConfig(
controller,
config.copyWith(
aiGatewayInjectionPolicy:
AiGatewayInjectionPolicyCopy.fromJsonValue(value),
),
);
},
),
const SizedBox(height: 16),
_InfoRow(
label: appText('托管 Skills', 'Managed Skills'),
value: '$managedSkillCount',
),
_InfoRow(
label: appText('托管 MCP', 'Managed MCP'),
value: '$managedMcpCount',
),
const SizedBox(height: 16),
...mountTargets.map(
(target) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MountTargetCard(target: target),
),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('协作流程概览', 'Workflow Overview'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 12),
_WorkflowStep(
label: '1',
emoji: '🎨',
title: 'Architect',
desc: appText(
'分析需求,分解任务',
'Analyze requirements, decompose tasks',
),
),
_WorkflowStep(
label: '2',
emoji: '🔧',
title: 'Engineer',
desc: appText('接收任务,实现代码', 'Receive tasks, implement code'),
),
_WorkflowStep(
label: '3',
emoji: '🔍',
title: 'Tester',
desc: appText('审阅代码,生成测试', 'Review code, generate tests'),
),
_WorkflowStep(
label: '',
emoji: '🔄',
title: appText('迭代(如需要)', 'Iterate (if needed)'),
desc: appText(
'Engineer 修复 → Tester 重新审阅',
'Engineer fixes → Tester re-reviews',
),
),
const SizedBox(height: 8),
Text(
appText(
'所有本地模型通过 Ollama默认 http://127.0.0.1:11434驱动无需 API 密钥即可运行。',
'All local models powered by Ollama (default http://127.0.0.1:11434), no API key required.',
),
style: theme.textTheme.bodySmall,
),
],
),
),
];
}
List<String> _getLocalModelOptions(SettingsSnapshot settings) {
// ollamaLocal
final defaultModel = settings.ollamaLocal.defaultModel;
if (defaultModel.isNotEmpty) {
return [
defaultModel,
'qwen2.5-coder:latest',
'gpt-oss:20b',
'glm-4.7-flash',
];
}
return const ['qwen2.5-coder:latest', 'gpt-oss:20b', 'glm-4.7-flash'];
}
List<Widget> _buildExperimental(
BuildContext context,
AppController controller,
@ -1343,6 +1739,13 @@ class _SettingsPageState extends State<SettingsPage> {
return controller.saveSettings(snapshot);
}
Future<void> _saveMultiAgentConfig(
AppController controller,
MultiAgentConfig config,
) {
return controller.saveMultiAgentConfig(config);
}
AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) {
return settings.aiGateway.copyWith(
name: _aiGatewayNameController.text.trim(),
@ -2243,6 +2646,72 @@ class _SwitchRow extends StatelessWidget {
}
}
class _MountTargetCard extends StatelessWidget {
const _MountTargetCard({required this.target});
final ManagedMountTargetState target;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final statusColor = target.available
? theme.colorScheme.primary
: theme.colorScheme.outline;
final summary = <String>[
'${appText('发现', 'Discovery')}: ${target.discoveryState}',
'${appText('同步', 'Sync')}: ${target.syncState}',
if (target.supportsSkills)
'${appText('技能', 'Skills')}: ${target.discoveredSkillCount}',
if (target.supportsMcp)
'${appText('MCP', 'MCP')}: ${target.discoveredMcpCount}',
if (target.supportsMcp)
'${appText('托管', 'Managed')}: ${target.managedMcpCount}',
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(target.label, style: theme.textTheme.titleMedium),
),
Text(
target.available
? appText('可用', 'Available')
: appText('未安装', 'Missing'),
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 8),
Text(summary.join(' · '), style: theme.textTheme.bodySmall),
if (target.detail.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(target.detail, style: theme.textTheme.bodyMedium),
],
],
),
),
);
}
}
class _AiGatewayFeedbackTheme {
const _AiGatewayFeedbackTheme({
required this.background,
@ -2303,3 +2772,187 @@ class _InfoRow extends StatelessWidget {
);
}
}
/// Agent
class _AgentRoleCard extends StatelessWidget {
const _AgentRoleCard({
required this.title,
required this.description,
required this.cliTool,
required this.model,
required this.enabled,
required this.cliOptions,
required this.modelOptions,
required this.onCliChanged,
required this.onModelChanged,
required this.onEnabledChanged,
});
final String title;
final String description;
final String cliTool;
final String model;
final bool enabled;
final List<String> cliOptions;
final List<String> modelOptions;
final ValueChanged<String> onCliChanged;
final ValueChanged<String> onModelChanged;
final ValueChanged<bool> onEnabledChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(description, style: theme.textTheme.bodySmall),
],
),
),
if (cliOptions.length > 1)
_SwitchRow(
label: appText('启用', 'Enabled'),
value: enabled,
onChanged: onEnabledChanged,
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('CLI', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
initialValue: cliOptions.contains(cliTool)
? cliTool
: cliOptions.first,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: cliOptions
.map(
(t) => DropdownMenuItem(value: t, child: Text(t)),
)
.toList(),
onChanged: (v) {
if (v != null) onCliChanged(v);
},
),
],
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('模型', 'Model'),
style: theme.textTheme.labelMedium,
),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
initialValue: modelOptions.contains(model)
? model
: modelOptions.first,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: modelOptions
.map(
(m) => DropdownMenuItem(
value: m,
child: Text(m, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: (v) {
if (v != null) onModelChanged(v);
},
),
],
),
),
],
),
],
),
);
}
}
///
class _WorkflowStep extends StatelessWidget {
const _WorkflowStep({
required this.label,
required this.emoji,
required this.title,
required this.desc,
});
final String label;
final String emoji;
final String title;
final String desc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primaryContainer,
),
child: Text(label, style: theme.textTheme.labelSmall),
),
const SizedBox(width: 12),
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.labelLarge),
Text(desc, style: theme.textTheme.bodySmall),
],
),
),
],
),
);
}
}

View File

@ -195,6 +195,7 @@ enum SettingsTab {
general,
workspace,
gateway,
agents,
appearance,
diagnostics,
experimental,
@ -206,6 +207,7 @@ extension SettingsTabCopy on SettingsTab {
SettingsTab.general => appText('通用', 'General'),
SettingsTab.workspace => appText('工作区', 'Workspace'),
SettingsTab.gateway => appText('集成', 'Integrations'),
SettingsTab.agents => appText('多 Agent', 'Multi-Agent'),
SettingsTab.appearance => appText('外观', 'Appearance'),
SettingsTab.diagnostics => appText('诊断', 'Diagnostics'),
SettingsTab.experimental => appText('实验特性', 'Experimental'),

View File

@ -10,6 +10,10 @@ import 'platform_environment.dart';
class CodexConfigBridge {
static const String _managedBlockStart = '# BEGIN XWORKMATE MANAGED BLOCK';
static const String _managedBlockEnd = '# END XWORKMATE MANAGED BLOCK';
static const String _managedMcpBlockStart =
'# BEGIN XWORKMATE MANAGED MCP BLOCK';
static const String _managedMcpBlockEnd =
'# END XWORKMATE MANAGED MCP BLOCK';
final String codexHome;
@ -118,24 +122,32 @@ class CodexConfigBridge {
}
String _stripManagedBlock(String content) {
return _stripBlock(content, _managedBlockStart, _managedBlockEnd);
}
String _stripManagedMcpBlock(String content) {
return _stripBlock(content, _managedMcpBlockStart, _managedMcpBlockEnd);
}
String _stripBlock(String content, String startMarker, String endMarker) {
if (content.isEmpty) {
return content;
}
var remaining = content;
while (true) {
final start = remaining.indexOf(_managedBlockStart);
final start = remaining.indexOf(startMarker);
if (start < 0) {
break;
}
final end = remaining.indexOf(_managedBlockEnd, start);
final end = remaining.indexOf(endMarker, start);
if (end < 0) {
remaining = remaining.substring(0, start);
break;
}
remaining =
remaining.substring(0, start) +
remaining.substring(end + _managedBlockEnd.length);
remaining.substring(end + endMarker.length);
}
return remaining;
}
@ -216,6 +228,55 @@ class CodexConfigBridge {
await configFile.writeAsString(buffer.toString());
}
Future<void> configureManagedMcpServers({
required List<CodexMcpServer> servers,
}) async {
final configDir = Directory(codexHome);
if (!await configDir.exists()) {
await configDir.create(recursive: true);
}
final configFile = File('$codexHome/config.toml');
final existingConfig = await configFile.exists()
? await configFile.readAsString()
: '';
final preserved = _stripManagedMcpBlock(existingConfig).trimRight();
final managedBlock = _buildManagedMcpBlock(servers);
final merged = preserved.isEmpty
? '$managedBlock\n'
: '$preserved\n\n$managedBlock\n';
await configFile.writeAsString(merged);
}
String _buildManagedMcpBlock(List<CodexMcpServer> servers) {
final buffer = StringBuffer()
..writeln(_managedMcpBlockStart)
..writeln('# Generated by XWorkmate - Managed MCP Server Configuration')
..writeln('# Last updated: ${DateTime.now().toIso8601String()}')
..writeln();
for (final server in servers) {
buffer.writeln('[mcp_servers.${server.name}]');
buffer.writeln('command = "${server.command}"');
if (server.args.isNotEmpty) {
buffer.writeln('args = ${_formatTomlArray(server.args)}');
}
if (server.env.isNotEmpty) {
buffer.writeln('[mcp_servers.${server.name}.env]');
for (final entry in server.env.entries) {
buffer.writeln('${entry.key} = "${entry.value}"');
}
}
buffer.writeln();
}
buffer.writeln(_managedMcpBlockEnd);
return buffer.toString().trimRight();
}
String _formatTomlArray(List<String> items) {
if (items.isEmpty) return '[]';
if (items.length == 1) return '["${items[0]}"]';

View File

@ -0,0 +1,223 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'multi_agent_orchestrator.dart';
import 'runtime_models.dart';
class MultiAgentBrokerServer {
MultiAgentBrokerServer(this._orchestrator);
final MultiAgentOrchestrator _orchestrator;
HttpServer? _server;
bool get isRunning => _server != null;
Uri? get wsUri => _server == null
? null
: Uri.parse('ws://127.0.0.1:${_server!.port}/multi-agent-broker');
Future<void> start() async {
if (_server != null) {
return;
}
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
unawaited(_listen());
}
Future<void> stop() async {
final server = _server;
_server = null;
await server?.close(force: true);
}
Future<void> _listen() async {
final server = _server;
if (server == null) {
return;
}
await for (final request in server) {
if (request.uri.path != '/multi-agent-broker' ||
!WebSocketTransformer.isUpgradeRequest(request)) {
request.response
..statusCode = HttpStatus.notFound
..close();
continue;
}
final socket = await WebSocketTransformer.upgrade(request);
unawaited(_handleSocket(socket));
}
}
Future<void> _handleSocket(WebSocket socket) async {
await for (final raw in socket) {
try {
final json = jsonDecode(raw as String) as Map<String, dynamic>;
final method = json['method'] as String? ?? '';
final id = json['id'];
if (method != 'run.start') {
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'error': <String, dynamic>{
'code': -32601,
'message': 'Method not found',
},
}),
);
continue;
}
final params =
(json['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
final attachments =
((params['attachments'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => CollaborationAttachment(
name: item['name']?.toString() ?? '',
description: item['description']?.toString() ?? '',
path: item['path']?.toString() ?? '',
),
)
.toList(growable: false);
final result = await _orchestrator.runCollaboration(
taskPrompt: params['taskPrompt'] as String? ?? '',
workingDirectory: params['workingDirectory'] as String? ?? '',
attachments: attachments,
selectedSkills:
((params['selectedSkills'] as List?) ?? const <Object>[])
.map((item) => item.toString())
.toList(growable: false),
aiGatewayBaseUrl: params['aiGatewayBaseUrl'] as String? ?? '',
aiGatewayApiKey: params['aiGatewayApiKey'] as String? ?? '',
onEvent: (event) {
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'multi_agent.event',
'params': event.toJson(),
}),
);
},
);
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': result.toJson(),
}),
);
} catch (error) {
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'error': <String, dynamic>{
'code': -32000,
'message': error.toString(),
},
}),
);
}
}
}
}
class MultiAgentBrokerClient {
MultiAgentBrokerClient(this._uri);
final Uri _uri;
Stream<MultiAgentRunEvent> runTask({
required String taskPrompt,
required String workingDirectory,
required List<CollaborationAttachment> attachments,
required List<String> selectedSkills,
required String aiGatewayBaseUrl,
required String aiGatewayApiKey,
}) async* {
final socket = await WebSocket.connect(_uri.toString());
final controller = StreamController<MultiAgentRunEvent>();
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
socket.listen(
(raw) {
final json = jsonDecode(raw as String) as Map<String, dynamic>;
final method = json['method'] as String?;
if (method == 'multi_agent.event') {
final params =
(json['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
controller.add(MultiAgentRunEvent.fromJson(params));
return;
}
if (json['id']?.toString() == requestId && json['result'] is Map) {
final result = (json['result'] as Map).cast<String, dynamic>();
controller.add(
MultiAgentRunEvent(
type: 'result',
title: 'Multi-Agent',
message: result['success'] == true
? 'Collaboration completed.'
: 'Collaboration failed.',
pending: false,
error: result['success'] != true,
data: result,
),
);
unawaited(controller.close());
unawaited(socket.close());
return;
}
if (json['error'] is Map) {
final error = (json['error'] as Map).cast<String, dynamic>();
controller.add(
MultiAgentRunEvent(
type: 'error',
title: 'Multi-Agent',
message: error['message']?.toString() ?? 'Broker error',
pending: false,
error: true,
),
);
unawaited(controller.close());
unawaited(socket.close());
}
},
onError: controller.addError,
onDone: () {
if (!controller.isClosed) {
controller.close();
}
},
cancelOnError: true,
);
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': requestId,
'method': 'run.start',
'params': <String, dynamic>{
'taskPrompt': taskPrompt,
'workingDirectory': workingDirectory,
'attachments': attachments
.map(
(item) => <String, dynamic>{
'name': item.name,
'description': item.description,
'path': item.path,
},
)
.toList(growable: false),
'selectedSkills': selectedSkills,
'aiGatewayBaseUrl': aiGatewayBaseUrl,
'aiGatewayApiKey': aiGatewayApiKey,
},
}),
);
yield* controller.stream;
}
}

View File

@ -0,0 +1,420 @@
import 'dart:convert';
import 'dart:io';
import 'codex_config_bridge.dart';
import 'opencode_config_bridge.dart';
import 'runtime_models.dart';
class MultiAgentMountManager {
MultiAgentMountManager({
CodexConfigBridge? codexConfigBridge,
OpencodeConfigBridge? opencodeConfigBridge,
}) : _adapters = <CliMountAdapter>[
CodexMountAdapter(codexConfigBridge ?? CodexConfigBridge()),
ClaudeMountAdapter(),
GeminiMountAdapter(),
OpencodeMountAdapter(opencodeConfigBridge ?? OpencodeConfigBridge()),
OpenClawMountAdapter(),
];
final List<CliMountAdapter> _adapters;
Future<MultiAgentConfig> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final states = <ManagedMountTargetState>[];
for (final adapter in _adapters) {
try {
states.add(
await adapter.reconcile(config: config, aiGatewayUrl: aiGatewayUrl),
);
} catch (error) {
states.add(
ManagedMountTargetState.placeholder(
targetId: adapter.targetId,
label: adapter.label,
supportsSkills: adapter.supportsSkills,
supportsMcp: adapter.supportsMcp,
supportsAiGatewayInjection: adapter.supportsAiGatewayInjection,
).copyWith(
available: await adapter.isInstalled(),
discoveryState: 'error',
syncState: 'error',
detail: error.toString(),
),
);
}
}
return config.copyWith(mountTargets: states);
}
}
abstract class CliMountAdapter {
String get targetId;
String get label;
bool get supportsSkills;
bool get supportsMcp;
bool get supportsAiGatewayInjection;
Future<bool> isInstalled();
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
});
Future<String> _runCommand(List<String> command) async {
final result = await Process.run(
command.first,
command.sublist(1),
runInShell: true,
);
final stdout = '${result.stdout}'.trim();
final stderr = '${result.stderr}'.trim();
return stdout.isNotEmpty ? stdout : stderr;
}
Future<int> _countListedEntries(List<String> command) async {
final output = await _runCommand(command);
if (output.isEmpty ||
output.contains('No MCP servers configured') ||
output.contains('No MCP servers configured yet') ||
output.contains('No MCP servers configured.')) {
return 0;
}
return output
.split('\n')
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.where((item) => !item.startsWith('Usage:'))
.where((item) => !item.startsWith(''))
.where((item) => !item.startsWith(''))
.where((item) => !item.startsWith(''))
.length;
}
Future<bool> _binaryExists(String command) async {
final check = await Process.run(
Platform.isWindows ? 'where' : 'which',
<String>[command],
runInShell: true,
);
return check.exitCode == 0 && '${check.stdout}'.trim().isNotEmpty;
}
int countMcpTomlSections(String content) {
return RegExp(
r'^\[mcp_servers\.[^\]]+\]',
multiLine: true,
).allMatches(content).length;
}
}
class CodexMountAdapter extends CliMountAdapter {
CodexMountAdapter(this._bridge);
final CodexConfigBridge _bridge;
@override
String get targetId => 'codex';
@override
String get label => 'Codex';
@override
bool get supportsSkills => true;
@override
bool get supportsMcp => true;
@override
bool get supportsAiGatewayInjection => true;
@override
Future<bool> isInstalled() => _binaryExists('codex');
@override
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final configFile = File('${_bridge.codexHome}/config.toml');
final content = await configFile.exists()
? await configFile.readAsString()
: '';
final discoveredMcpCount = countMcpTomlSections(content);
final managedMcpServers = config.managedMcpServers
.where((item) => item.enabled && item.command.trim().isNotEmpty)
.toList(growable: false);
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
await _bridge.configureManagedMcpServers(
servers: managedMcpServers
.map(
(item) => CodexMcpServer(
name: item.id,
command: item.command,
args: item.args,
),
)
.toList(growable: false),
);
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: !available
? 'missing'
: config.autoSync
? 'ready'
: 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: managedMcpServers.length,
detail: aiGatewayUrl.isNotEmpty
? 'AI Gateway uses launch-scoped defaults for collaboration runs.'
: 'AI Gateway not configured.',
);
}
}
class ClaudeMountAdapter extends CliMountAdapter {
@override
String get targetId => 'claude';
@override
String get label => 'Claude';
@override
bool get supportsSkills => true;
@override
bool get supportsMcp => true;
@override
bool get supportsAiGatewayInjection => true;
@override
Future<bool> isInstalled() => _binaryExists('claude');
@override
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final discoveredMcpCount = available
? await _countListedEntries(<String>['claude', 'mcp', 'list'])
: 0;
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: config.managedMcpServers
.where((item) => item.enabled)
.length,
detail:
'MCP discovery uses `claude mcp list`; AI Gateway stays launch-scoped.',
);
}
}
class GeminiMountAdapter extends CliMountAdapter {
@override
String get targetId => 'gemini';
@override
String get label => 'Gemini';
@override
bool get supportsSkills => true;
@override
bool get supportsMcp => true;
@override
bool get supportsAiGatewayInjection => true;
@override
Future<bool> isInstalled() => _binaryExists('gemini');
@override
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final discoveredMcpCount = available
? await _countListedEntries(<String>['gemini', 'mcp', 'list'])
: 0;
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: config.managedMcpServers
.where((item) => item.enabled)
.length,
detail:
'MCP discovery uses `gemini mcp list`; AI Gateway stays launch-scoped.',
);
}
}
class OpencodeMountAdapter extends CliMountAdapter {
OpencodeMountAdapter(this._bridge);
final OpencodeConfigBridge _bridge;
@override
String get targetId => 'opencode';
@override
String get label => 'OpenCode';
@override
bool get supportsSkills => true;
@override
bool get supportsMcp => true;
@override
bool get supportsAiGatewayInjection => true;
@override
Future<bool> isInstalled() => _binaryExists('opencode');
@override
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final content = await _bridge.readConfig();
final discoveredMcpCount = countMcpTomlSections(content);
final managedMcpServers = config.managedMcpServers
.where((item) => item.enabled)
.toList(growable: false);
if (available && config.autoSync && managedMcpServers.isNotEmpty) {
await _bridge.configureManagedMcpServers(
servers: managedMcpServers
.map(
(item) => OpencodeMcpServer(
name: item.id,
command: item.command,
url: item.url,
args: item.args,
),
)
.toList(growable: false),
);
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: !available
? 'missing'
: config.autoSync
? 'ready'
: 'disabled',
discoveredMcpCount: discoveredMcpCount,
managedMcpCount: managedMcpServers.length,
detail: 'Managed MCP config is preserved in ~/.opencode/config.toml.',
);
}
}
class OpenClawMountAdapter extends CliMountAdapter {
@override
String get targetId => 'openclaw';
@override
String get label => 'OpenClaw';
@override
bool get supportsSkills => true;
@override
bool get supportsMcp => false;
@override
bool get supportsAiGatewayInjection => true;
@override
Future<bool> isInstalled() => _binaryExists('openclaw');
@override
Future<ManagedMountTargetState> reconcile({
required MultiAgentConfig config,
required String aiGatewayUrl,
}) async {
final available = await isInstalled();
final configFile = File(
'${Platform.environment['HOME'] ?? ''}/.openclaw/openclaw.json',
);
var discoveredSkillCount = 0;
var detail = 'OpenClaw acts as the host/control plane mount.';
if (await configFile.exists()) {
try {
final decoded = jsonDecode(await configFile.readAsString());
final agents =
(decoded is Map<String, dynamic> &&
decoded['agents'] is Map<String, dynamic> &&
(decoded['agents'] as Map<String, dynamic>)['list'] is List)
? ((decoded['agents'] as Map<String, dynamic>)['list'] as List)
.length
: 0;
final skillsDir = Directory(
'${Platform.environment['HOME'] ?? ''}/.openclaw/skills',
);
if (await skillsDir.exists()) {
discoveredSkillCount = await skillsDir
.list()
.where((entity) => entity is File || entity is Directory)
.length;
}
detail = 'agents: $agents · skills: $discoveredSkillCount';
} catch (_) {
detail = 'OpenClaw config detected but could not be fully parsed.';
}
}
return ManagedMountTargetState.placeholder(
targetId: targetId,
label: label,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
).copyWith(
available: available,
discoveryState: available ? 'ready' : 'missing',
syncState: available && config.autoSync ? 'launch-only' : 'disabled',
discoveredSkillCount: discoveredSkillCount,
detail: detail,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
import 'dart:io';
class OpencodeConfigBridge {
OpencodeConfigBridge({String? opencodeHome})
: opencodeHome =
opencodeHome ?? '${Platform.environment['HOME'] ?? ''}/.opencode';
static const String _managedMcpBlockStart =
'# BEGIN XWORKMATE MANAGED MCP BLOCK';
static const String _managedMcpBlockEnd = '# END XWORKMATE MANAGED MCP BLOCK';
final String opencodeHome;
Future<void> configureManagedMcpServers({
required List<OpencodeMcpServer> servers,
}) async {
final configDir = Directory(opencodeHome);
if (!await configDir.exists()) {
await configDir.create(recursive: true);
}
final configFile = File('$opencodeHome/config.toml');
final existingConfig = await configFile.exists()
? await configFile.readAsString()
: '';
final preserved = _stripManagedMcpBlock(existingConfig).trimRight();
final managedBlock = _buildManagedMcpBlock(servers);
final merged = preserved.isEmpty
? '$managedBlock\n'
: '$preserved\n\n$managedBlock\n';
await configFile.writeAsString(merged);
}
Future<String> readConfig() async {
final configFile = File('$opencodeHome/config.toml');
if (!await configFile.exists()) {
return '';
}
return configFile.readAsString();
}
String _stripManagedMcpBlock(String content) {
if (content.isEmpty) {
return content;
}
var remaining = content;
while (true) {
final start = remaining.indexOf(_managedMcpBlockStart);
if (start < 0) {
break;
}
final end = remaining.indexOf(_managedMcpBlockEnd, start);
if (end < 0) {
remaining = remaining.substring(0, start);
break;
}
remaining =
remaining.substring(0, start) +
remaining.substring(end + _managedMcpBlockEnd.length);
}
return remaining;
}
String _buildManagedMcpBlock(List<OpencodeMcpServer> servers) {
final buffer = StringBuffer()
..writeln(_managedMcpBlockStart)
..writeln('# Generated by XWorkmate - Managed MCP Server Configuration')
..writeln('# Last updated: ${DateTime.now().toIso8601String()}')
..writeln();
for (final server in servers) {
buffer.writeln('[mcp_servers.${server.name}]');
if (server.url.trim().isNotEmpty) {
buffer.writeln('url = "${server.url.trim()}"');
} else {
buffer.writeln('type = "stdio"');
buffer.writeln('command = "${server.command}"');
if (server.args.isNotEmpty) {
buffer.writeln('args = ${_formatTomlArray(server.args)}');
}
}
if (server.env.isNotEmpty) {
final entries = server.env.entries
.map((entry) => '${entry.key} = "${entry.value}"')
.join(', ');
buffer.writeln('env = { $entries }');
}
buffer.writeln();
}
buffer.writeln(_managedMcpBlockEnd);
return buffer.toString().trimRight();
}
String _formatTomlArray(List<String> items) {
if (items.isEmpty) {
return '[]';
}
return '[${items.map((item) => '"$item"').join(', ')}]';
}
}
class OpencodeMcpServer {
const OpencodeMcpServer({
required this.name,
this.command = '',
this.url = '',
this.args = const <String>[],
this.env = const <String, String>{},
});
final String name;
final String command;
final String url;
final List<String> args;
final Map<String, String> env;
}

View File

@ -886,6 +886,7 @@ class SettingsSnapshot {
required this.ollamaCloud,
required this.vault,
required this.aiGateway,
required this.multiAgent,
required this.experimentalCanvas,
required this.experimentalBridge,
required this.experimentalDebug,
@ -915,6 +916,7 @@ class SettingsSnapshot {
final OllamaCloudConfig ollamaCloud;
final VaultConfig vault;
final AiGatewayProfile aiGateway;
final MultiAgentConfig multiAgent;
final bool experimentalCanvas;
final bool experimentalBridge;
final bool experimentalDebug;
@ -945,6 +947,7 @@ class SettingsSnapshot {
ollamaCloud: OllamaCloudConfig.defaults(),
vault: VaultConfig.defaults(),
aiGateway: AiGatewayProfile.defaults(),
multiAgent: MultiAgentConfig.defaults(),
experimentalCanvas: false,
experimentalBridge: false,
experimentalDebug: false,
@ -976,6 +979,7 @@ class SettingsSnapshot {
OllamaCloudConfig? ollamaCloud,
VaultConfig? vault,
AiGatewayProfile? aiGateway,
MultiAgentConfig? multiAgent,
bool? experimentalCanvas,
bool? experimentalBridge,
bool? experimentalDebug,
@ -1005,6 +1009,7 @@ class SettingsSnapshot {
ollamaCloud: ollamaCloud ?? this.ollamaCloud,
vault: vault ?? this.vault,
aiGateway: aiGateway ?? this.aiGateway,
multiAgent: multiAgent ?? this.multiAgent,
experimentalCanvas: experimentalCanvas ?? this.experimentalCanvas,
experimentalBridge: experimentalBridge ?? this.experimentalBridge,
experimentalDebug: experimentalDebug ?? this.experimentalDebug,
@ -1041,6 +1046,7 @@ class SettingsSnapshot {
'ollamaCloud': ollamaCloud.toJson(),
'vault': vault.toJson(),
'aiGateway': aiGateway.toJson(),
'multiAgent': multiAgent.toJson(),
'experimentalCanvas': experimentalCanvas,
'experimentalBridge': experimentalBridge,
'experimentalDebug': experimentalDebug,
@ -1115,6 +1121,9 @@ class SettingsSnapshot {
(json['apisix'] as Map?)?.cast<String, dynamic>() ??
const {},
),
multiAgent: MultiAgentConfig.fromJson(
(json['multiAgent'] as Map?)?.cast<String, dynamic>() ?? const {},
),
experimentalCanvas: json['experimentalCanvas'] as bool? ?? false,
experimentalBridge: json['experimentalBridge'] as bool? ?? false,
experimentalDebug: json['experimentalDebug'] as bool? ?? false,
@ -1835,3 +1844,736 @@ class LocalDeviceIdentity {
);
}
}
/// Agent
enum MultiAgentRole {
architect, // /
engineer, //
testerDoc, // /
}
extension MultiAgentRoleCopy on MultiAgentRole {
String get label => switch (this) {
MultiAgentRole.architect => 'Architect调度者',
MultiAgentRole.engineer => 'Engineer工程师',
MultiAgentRole.testerDoc => 'Tester/Doc评审',
};
String get description => switch (this) {
MultiAgentRole.architect => '负责任务分解、流程设计、宏观规划',
MultiAgentRole.engineer => '负责代码实现、重构、调试',
MultiAgentRole.testerDoc => '负责测试用例生成、代码审阅、文档撰写',
};
}
enum AiGatewayInjectionPolicy { disabled, launchScoped, appManagedDefault }
extension AiGatewayInjectionPolicyCopy on AiGatewayInjectionPolicy {
String get label => switch (this) {
AiGatewayInjectionPolicy.disabled => appText('禁用', 'Disabled'),
AiGatewayInjectionPolicy.launchScoped => appText(
'仅当前协作运行',
'Launch scoped',
),
AiGatewayInjectionPolicy.appManagedDefault => appText(
'XWorkmate 默认',
'XWorkmate default',
),
};
static AiGatewayInjectionPolicy fromJsonValue(String? value) {
return AiGatewayInjectionPolicy.values.firstWhere(
(item) => item.name == value,
orElse: () => AiGatewayInjectionPolicy.appManagedDefault,
);
}
}
/// Agent Worker
class AgentWorkerConfig {
const AgentWorkerConfig({
required this.role,
required this.cliTool,
required this.model,
required this.enabled,
this.maxRetries = 2,
});
final MultiAgentRole role;
final String cliTool; // 'claude' | 'codex' | 'gemini'
final String model;
final bool enabled;
final int maxRetries;
AgentWorkerConfig copyWith({
MultiAgentRole? role,
String? cliTool,
String? model,
bool? enabled,
int? maxRetries,
}) {
return AgentWorkerConfig(
role: role ?? this.role,
cliTool: cliTool ?? this.cliTool,
model: model ?? this.model,
enabled: enabled ?? this.enabled,
maxRetries: maxRetries ?? this.maxRetries,
);
}
}
class ManagedSkillEntry {
const ManagedSkillEntry({
required this.key,
required this.label,
required this.source,
required this.selected,
});
final String key;
final String label;
final String source;
final bool selected;
ManagedSkillEntry copyWith({
String? key,
String? label,
String? source,
bool? selected,
}) {
return ManagedSkillEntry(
key: key ?? this.key,
label: label ?? this.label,
source: source ?? this.source,
selected: selected ?? this.selected,
);
}
Map<String, dynamic> toJson() {
return {'key': key, 'label': label, 'source': source, 'selected': selected};
}
factory ManagedSkillEntry.fromJson(Map<String, dynamic> json) {
return ManagedSkillEntry(
key: json['key'] as String? ?? '',
label: json['label'] as String? ?? '',
source: json['source'] as String? ?? '',
selected: json['selected'] as bool? ?? false,
);
}
}
class ManagedMcpServerEntry {
const ManagedMcpServerEntry({
required this.id,
required this.name,
required this.transport,
required this.command,
required this.url,
required this.args,
required this.envKeys,
required this.enabled,
});
final String id;
final String name;
final String transport;
final String command;
final String url;
final List<String> args;
final List<String> envKeys;
final bool enabled;
ManagedMcpServerEntry copyWith({
String? id,
String? name,
String? transport,
String? command,
String? url,
List<String>? args,
List<String>? envKeys,
bool? enabled,
}) {
return ManagedMcpServerEntry(
id: id ?? this.id,
name: name ?? this.name,
transport: transport ?? this.transport,
command: command ?? this.command,
url: url ?? this.url,
args: args ?? this.args,
envKeys: envKeys ?? this.envKeys,
enabled: enabled ?? this.enabled,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'transport': transport,
'command': command,
'url': url,
'args': args,
'envKeys': envKeys,
'enabled': enabled,
};
}
factory ManagedMcpServerEntry.fromJson(Map<String, dynamic> json) {
final rawArgs = json['args'];
final rawEnvKeys = json['envKeys'];
return ManagedMcpServerEntry(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
transport: json['transport'] as String? ?? 'stdio',
command: json['command'] as String? ?? '',
url: json['url'] as String? ?? '',
args: rawArgs is List
? rawArgs.map((item) => item.toString()).toList(growable: false)
: const <String>[],
envKeys: rawEnvKeys is List
? rawEnvKeys.map((item) => item.toString()).toList(growable: false)
: const <String>[],
enabled: json['enabled'] as bool? ?? true,
);
}
}
class ManagedMountTargetState {
const ManagedMountTargetState({
required this.targetId,
required this.label,
required this.available,
required this.supportsSkills,
required this.supportsMcp,
required this.supportsAiGatewayInjection,
required this.discoveryState,
required this.syncState,
required this.discoveredSkillCount,
required this.discoveredMcpCount,
required this.managedMcpCount,
required this.detail,
});
final String targetId;
final String label;
final bool available;
final bool supportsSkills;
final bool supportsMcp;
final bool supportsAiGatewayInjection;
final String discoveryState;
final String syncState;
final int discoveredSkillCount;
final int discoveredMcpCount;
final int managedMcpCount;
final String detail;
ManagedMountTargetState copyWith({
String? targetId,
String? label,
bool? available,
bool? supportsSkills,
bool? supportsMcp,
bool? supportsAiGatewayInjection,
String? discoveryState,
String? syncState,
int? discoveredSkillCount,
int? discoveredMcpCount,
int? managedMcpCount,
String? detail,
}) {
return ManagedMountTargetState(
targetId: targetId ?? this.targetId,
label: label ?? this.label,
available: available ?? this.available,
supportsSkills: supportsSkills ?? this.supportsSkills,
supportsMcp: supportsMcp ?? this.supportsMcp,
supportsAiGatewayInjection:
supportsAiGatewayInjection ?? this.supportsAiGatewayInjection,
discoveryState: discoveryState ?? this.discoveryState,
syncState: syncState ?? this.syncState,
discoveredSkillCount: discoveredSkillCount ?? this.discoveredSkillCount,
discoveredMcpCount: discoveredMcpCount ?? this.discoveredMcpCount,
managedMcpCount: managedMcpCount ?? this.managedMcpCount,
detail: detail ?? this.detail,
);
}
Map<String, dynamic> toJson() {
return {
'targetId': targetId,
'label': label,
'available': available,
'supportsSkills': supportsSkills,
'supportsMcp': supportsMcp,
'supportsAiGatewayInjection': supportsAiGatewayInjection,
'discoveryState': discoveryState,
'syncState': syncState,
'discoveredSkillCount': discoveredSkillCount,
'discoveredMcpCount': discoveredMcpCount,
'managedMcpCount': managedMcpCount,
'detail': detail,
};
}
factory ManagedMountTargetState.fromJson(Map<String, dynamic> json) {
return ManagedMountTargetState(
targetId: json['targetId'] as String? ?? '',
label: json['label'] as String? ?? '',
available: json['available'] as bool? ?? false,
supportsSkills: json['supportsSkills'] as bool? ?? false,
supportsMcp: json['supportsMcp'] as bool? ?? false,
supportsAiGatewayInjection:
json['supportsAiGatewayInjection'] as bool? ?? false,
discoveryState: json['discoveryState'] as String? ?? 'idle',
syncState: json['syncState'] as String? ?? 'idle',
discoveredSkillCount: json['discoveredSkillCount'] as int? ?? 0,
discoveredMcpCount: json['discoveredMcpCount'] as int? ?? 0,
managedMcpCount: json['managedMcpCount'] as int? ?? 0,
detail: json['detail'] as String? ?? '',
);
}
factory ManagedMountTargetState.placeholder({
required String targetId,
required String label,
required bool supportsSkills,
required bool supportsMcp,
required bool supportsAiGatewayInjection,
}) {
return ManagedMountTargetState(
targetId: targetId,
label: label,
available: false,
supportsSkills: supportsSkills,
supportsMcp: supportsMcp,
supportsAiGatewayInjection: supportsAiGatewayInjection,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
);
}
static List<ManagedMountTargetState> defaults() {
return const <ManagedMountTargetState>[
ManagedMountTargetState(
targetId: 'codex',
label: 'Codex',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'claude',
label: 'Claude',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'gemini',
label: 'Gemini',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'opencode',
label: 'OpenCode',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'openclaw',
label: 'OpenClaw',
available: false,
supportsSkills: true,
supportsMcp: false,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
];
}
}
/// Agent
class MultiAgentConfig {
const MultiAgentConfig({
required this.enabled,
required this.autoSync,
required this.architect,
required this.engineer,
required this.tester,
required this.ollamaEndpoint,
required this.maxIterations,
required this.minAcceptableScore,
required this.timeoutSeconds,
required this.aiGatewayInjectionPolicy,
required this.managedSkills,
required this.managedMcpServers,
required this.mountTargets,
});
final bool enabled;
final bool autoSync;
final AgentWorkerConfig architect;
final AgentWorkerConfig engineer;
final AgentWorkerConfig tester;
final String ollamaEndpoint;
final int maxIterations;
final int minAcceptableScore;
final int timeoutSeconds;
final AiGatewayInjectionPolicy aiGatewayInjectionPolicy;
final List<ManagedSkillEntry> managedSkills;
final List<ManagedMcpServerEntry> managedMcpServers;
final List<ManagedMountTargetState> mountTargets;
/// Architect 便访
bool get architectEnabled => architect.enabled;
String get architectTool => architect.cliTool;
String get architectModel => architect.model;
/// Engineer 便访
String get engineerTool => engineer.cliTool;
String get engineerModel => engineer.model;
/// Tester 便访
String get testerTool => tester.cliTool;
String get testerModel => tester.model;
factory MultiAgentConfig.defaults() {
return MultiAgentConfig(
enabled: false,
autoSync: true,
architect: const AgentWorkerConfig(
role: MultiAgentRole.architect,
cliTool: 'gemini',
model: 'gemini-2.0-flash',
enabled: true,
),
engineer: const AgentWorkerConfig(
role: MultiAgentRole.engineer,
cliTool: 'claude',
model: 'qwen2.5-coder:latest',
enabled: true,
),
tester: const AgentWorkerConfig(
role: MultiAgentRole.testerDoc,
cliTool: 'codex',
model: 'gpt-oss:20b',
enabled: true,
),
ollamaEndpoint: 'http://127.0.0.1:11434',
maxIterations: 3,
minAcceptableScore: 7,
timeoutSeconds: 120,
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.appManagedDefault,
managedSkills: const <ManagedSkillEntry>[],
managedMcpServers: const <ManagedMcpServerEntry>[],
mountTargets: const <ManagedMountTargetState>[
ManagedMountTargetState(
targetId: 'codex',
label: 'Codex',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'claude',
label: 'Claude',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'gemini',
label: 'Gemini',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'opencode',
label: 'OpenCode',
available: false,
supportsSkills: true,
supportsMcp: true,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
ManagedMountTargetState(
targetId: 'openclaw',
label: 'OpenClaw',
available: false,
supportsSkills: true,
supportsMcp: false,
supportsAiGatewayInjection: true,
discoveryState: 'idle',
syncState: 'idle',
discoveredSkillCount: 0,
discoveredMcpCount: 0,
managedMcpCount: 0,
detail: '',
),
],
);
}
MultiAgentConfig copyWith({
bool? enabled,
bool? autoSync,
AgentWorkerConfig? architect,
AgentWorkerConfig? engineer,
AgentWorkerConfig? tester,
String? ollamaEndpoint,
int? maxIterations,
int? minAcceptableScore,
int? timeoutSeconds,
AiGatewayInjectionPolicy? aiGatewayInjectionPolicy,
List<ManagedSkillEntry>? managedSkills,
List<ManagedMcpServerEntry>? managedMcpServers,
List<ManagedMountTargetState>? mountTargets,
}) {
return MultiAgentConfig(
enabled: enabled ?? this.enabled,
autoSync: autoSync ?? this.autoSync,
architect: architect ?? this.architect,
engineer: engineer ?? this.engineer,
tester: tester ?? this.tester,
ollamaEndpoint: ollamaEndpoint ?? this.ollamaEndpoint,
maxIterations: maxIterations ?? this.maxIterations,
minAcceptableScore: minAcceptableScore ?? this.minAcceptableScore,
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
aiGatewayInjectionPolicy:
aiGatewayInjectionPolicy ?? this.aiGatewayInjectionPolicy,
managedSkills: managedSkills ?? this.managedSkills,
managedMcpServers: managedMcpServers ?? this.managedMcpServers,
mountTargets: mountTargets ?? this.mountTargets,
);
}
Map<String, dynamic> toJson() {
return {
'enabled': enabled,
'autoSync': autoSync,
'architect': {
'role': architect.role.name,
'cliTool': architect.cliTool,
'model': architect.model,
'enabled': architect.enabled,
'maxRetries': architect.maxRetries,
},
'engineer': {
'role': engineer.role.name,
'cliTool': engineer.cliTool,
'model': engineer.model,
'enabled': engineer.enabled,
'maxRetries': engineer.maxRetries,
},
'tester': {
'role': tester.role.name,
'cliTool': tester.cliTool,
'model': tester.model,
'enabled': tester.enabled,
'maxRetries': tester.maxRetries,
},
'ollamaEndpoint': ollamaEndpoint,
'maxIterations': maxIterations,
'minAcceptableScore': minAcceptableScore,
'timeoutSeconds': timeoutSeconds,
'aiGatewayInjectionPolicy': aiGatewayInjectionPolicy.name,
'managedSkills': managedSkills.map((item) => item.toJson()).toList(),
'managedMcpServers': managedMcpServers
.map((item) => item.toJson())
.toList(),
'mountTargets': mountTargets.map((item) => item.toJson()).toList(),
};
}
factory MultiAgentConfig.fromJson(Map<String, dynamic> json) {
final defaults = MultiAgentConfig.defaults();
final architectJson = json['architect'] as Map<String, dynamic>? ?? {};
final engineerJson = json['engineer'] as Map<String, dynamic>? ?? {};
final testerJson = json['tester'] as Map<String, dynamic>? ?? {};
final rawManagedSkills = json['managedSkills'];
final rawManagedMcpServers = json['managedMcpServers'];
final rawMountTargets = json['mountTargets'];
AgentWorkerConfig parseWorker(
Map<String, dynamic> m,
MultiAgentRole role,
String defaultTool,
) {
return AgentWorkerConfig(
role: role,
cliTool: m['cliTool'] as String? ?? defaultTool,
model: m['model'] as String? ?? '',
enabled: m['enabled'] as bool? ?? true,
maxRetries: m['maxRetries'] as int? ?? 2,
);
}
return MultiAgentConfig(
enabled: json['enabled'] as bool? ?? false,
autoSync: json['autoSync'] as bool? ?? defaults.autoSync,
architect: parseWorker(architectJson, MultiAgentRole.architect, 'gemini'),
engineer: parseWorker(engineerJson, MultiAgentRole.engineer, 'claude'),
tester: parseWorker(testerJson, MultiAgentRole.testerDoc, 'codex'),
ollamaEndpoint:
json['ollamaEndpoint'] as String? ?? defaults.ollamaEndpoint,
maxIterations: json['maxIterations'] as int? ?? defaults.maxIterations,
minAcceptableScore:
json['minAcceptableScore'] as int? ?? defaults.minAcceptableScore,
timeoutSeconds: json['timeoutSeconds'] as int? ?? defaults.timeoutSeconds,
aiGatewayInjectionPolicy: AiGatewayInjectionPolicyCopy.fromJsonValue(
json['aiGatewayInjectionPolicy'] as String?,
),
managedSkills: rawManagedSkills is List
? rawManagedSkills
.whereType<Map>()
.map(
(item) =>
ManagedSkillEntry.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false)
: defaults.managedSkills,
managedMcpServers: rawManagedMcpServers is List
? rawManagedMcpServers
.whereType<Map>()
.map(
(item) => ManagedMcpServerEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.toList(growable: false)
: defaults.managedMcpServers,
mountTargets: rawMountTargets is List
? rawMountTargets
.whereType<Map>()
.map(
(item) => ManagedMountTargetState.fromJson(
item.cast<String, dynamic>(),
),
)
.toList(growable: false)
: defaults.mountTargets,
);
}
}
class MultiAgentRunEvent {
const MultiAgentRunEvent({
required this.type,
required this.title,
required this.message,
required this.pending,
required this.error,
this.role,
this.iteration,
this.score,
this.data = const <String, dynamic>{},
});
final String type;
final String title;
final String message;
final bool pending;
final bool error;
final String? role;
final int? iteration;
final int? score;
final Map<String, dynamic> data;
Map<String, dynamic> toJson() {
return {
'type': type,
'title': title,
'message': message,
'pending': pending,
'error': error,
if (role != null) 'role': role,
if (iteration != null) 'iteration': iteration,
if (score != null) 'score': score,
'data': data,
};
}
factory MultiAgentRunEvent.fromJson(Map<String, dynamic> json) {
return MultiAgentRunEvent(
type: json['type'] as String? ?? 'status',
title: json['title'] as String? ?? '',
message: json['message'] as String? ?? '',
pending: json['pending'] as bool? ?? false,
error: json['error'] as bool? ?? false,
role: json['role'] as String?,
iteration: (json['iteration'] as num?)?.toInt(),
score: (json['score'] as num?)?.toInt(),
data:
(json['data'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{},
);
}
}

View File

@ -118,6 +118,45 @@ void main() {
expect(content, contains('TEST = "value"'));
});
test('configureManagedMcpServers preserves user MCP entries', () async {
final configFile = File('${tempDir.path}/config.toml');
await configFile.writeAsString('''
[mcp_servers.user_server]
command = "user-mcp"
args = ["--stdio"]
''');
await bridge.configureManagedMcpServers(
servers: const <CodexMcpServer>[
CodexMcpServer(
name: 'xworkmate_server',
command: 'xworkmate-mcp',
args: <String>['--port', '7777'],
),
],
);
await bridge.configureManagedMcpServers(
servers: const <CodexMcpServer>[
CodexMcpServer(
name: 'xworkmate_server',
command: 'xworkmate-mcp',
args: <String>['--port', '8888'],
),
],
);
final content = await configFile.readAsString();
expect(content, contains('[mcp_servers.user_server]'));
expect(content, contains('command = "user-mcp"'));
expect(content, contains('[mcp_servers.xworkmate_server]'));
expect(content, contains('"8888"'));
expect(
'# BEGIN XWORKMATE MANAGED MCP BLOCK'.allMatches(content).length,
1,
);
expect(content, isNot(contains('"7777"')));
});
test('hasConfig returns correct value', () async {
expect(await bridge.hasConfig(), isFalse);

View File

@ -0,0 +1,85 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/opencode_config_bridge.dart';
void main() {
group('OpencodeConfigBridge', () {
late Directory tempDir;
late OpencodeConfigBridge bridge;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp(
'opencode-config-bridge-',
);
bridge = OpencodeConfigBridge(opencodeHome: tempDir.path);
});
tearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('configureManagedMcpServers preserves user config', () async {
final configFile = File('${tempDir.path}/config.toml');
await configFile.writeAsString('''
[model]
name = "user-default"
[mcp_servers.user_server]
type = "stdio"
command = "user-mcp"
''');
await bridge.configureManagedMcpServers(
servers: const <OpencodeMcpServer>[
OpencodeMcpServer(
name: 'xworkmate_server',
command: 'xworkmate-mcp',
args: <String>['--stdio'],
),
],
);
final content = await configFile.readAsString();
expect(content, contains('[model]'));
expect(content, contains('name = "user-default"'));
expect(content, contains('[mcp_servers.user_server]'));
expect(content, contains('[mcp_servers.xworkmate_server]'));
expect(content, contains('# BEGIN XWORKMATE MANAGED MCP BLOCK'));
});
test(
'configureManagedMcpServers updates managed block without duplication',
() async {
await bridge.configureManagedMcpServers(
servers: const <OpencodeMcpServer>[
OpencodeMcpServer(
name: 'xworkmate_server',
command: 'xworkmate-mcp',
args: <String>['--port', '3000'],
),
],
);
await bridge.configureManagedMcpServers(
servers: const <OpencodeMcpServer>[
OpencodeMcpServer(
name: 'xworkmate_server',
command: 'xworkmate-mcp',
args: <String>['--port', '3001'],
),
],
);
final content = await bridge.readConfig();
expect(
'# BEGIN XWORKMATE MANAGED MCP BLOCK'.allMatches(content).length,
1,
);
expect(content, contains('"3001"'));
expect(content, isNot(contains('"3000"')));
},
);
});
}

View File

@ -128,6 +128,77 @@ void main() {
},
);
test(
'SecureConfigStore persists multi-agent settings without secrets in snapshot json',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-config-store-multi-agent-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final databasePath = '${tempDirectory.path}/settings.sqlite3';
final store = SecureConfigStore(
databasePathResolver: () async => databasePath,
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final snapshot = SettingsSnapshot.defaults().copyWith(
multiAgent: MultiAgentConfig.defaults().copyWith(
enabled: true,
autoSync: false,
aiGatewayInjectionPolicy: AiGatewayInjectionPolicy.launchScoped,
architect: const AgentWorkerConfig(
role: MultiAgentRole.architect,
cliTool: 'gemini',
model: 'gemini-2.5-pro',
enabled: true,
),
managedSkills: const <ManagedSkillEntry>[
ManagedSkillEntry(
key: 'calm_compact_workspace_system',
label: 'Calm Compact Workspace System',
source: '/Users/test/.codex/skills/calm_compact_workspace_system',
selected: true,
),
],
managedMcpServers: const <ManagedMcpServerEntry>[
ManagedMcpServerEntry(
id: 'xworkmate/gateway',
name: 'XWorkmate Gateway',
transport: 'stdio',
command: 'xworkmate-mcp',
url: '',
args: <String>['--stdio'],
envKeys: <String>[],
enabled: true,
),
],
),
);
await store.saveSettingsSnapshot(snapshot);
final loadedSnapshot = await store.loadSettingsSnapshot();
final encoded = loadedSnapshot.toJsonString();
expect(loadedSnapshot.multiAgent.enabled, isTrue);
expect(loadedSnapshot.multiAgent.autoSync, isFalse);
expect(
loadedSnapshot.multiAgent.aiGatewayInjectionPolicy,
AiGatewayInjectionPolicy.launchScoped,
);
expect(loadedSnapshot.multiAgent.architect.model, 'gemini-2.5-pro');
expect(loadedSnapshot.multiAgent.managedSkills, hasLength(1));
expect(loadedSnapshot.multiAgent.managedMcpServers, hasLength(1));
expect(encoded, contains('"multiAgent"'));
expect(encoded, isNot(contains('ai-gateway-secret')));
expect(encoded, isNot(contains('gateway_token')));
},
);
test(
'SecureConfigStore dispose closes sqlite handle and allows reopening the same database path',
() async {