init easy-code

This commit is contained in:
应文浩wenhao.ying@xiaobao100.com 2026-04-06 07:24:24 +08:00
commit e25ac591a7
412 changed files with 31257 additions and 0 deletions

482
.gitignore vendored Normal file
View File

@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

23
Directory.Build.props Normal file
View File

@ -0,0 +1,23 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>13.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsAotCompatible>true</IsAotCompatible>
<PublishAot>true</PublishAot>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>CS1591;CS1998;CS0168;CS0219;IL2026;IL2062;IL2070;IL2072;IL2075;IL2087;IL2118;IL2126;IL3050</NoWarn>
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
<IsTestProject>true</IsTestProject>
<IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,337 @@
# Terminal.Gui 终端 UI
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `../../src/screens/REPL.tsx` + `../../src/components/`50+ React/Ink 组件)
> 交叉参考: [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
---
## 概述
本模块用 Terminal.Gui 重新实现原始项目的 React/Ink 终端 UI 层。原始 `REPL.tsx` 是约 800 行的 React 函数式组件,依赖 Ink 的虚拟 DOM 将 JSX 渲染到终端。.NET 重写将其拆分为 `TerminalApp`(应用入口与主题)和 `REPLScreen`(主交互窗口),并将 50+ Ink 组件中最关键的两个(`PermissionDialog``CompanionSpriteView`)映射为 Terminal.Gui 的 `Dialog``View` 子类。
---
## 15.1 应用启动
`TerminalApp` 是整个 UI 的入口,对应原始 `cli.tsx``REPL.tsx` 的初始化链路。
```csharp
/// <summary>
/// Terminal.Gui 应用入口
/// 对应原始 cli.tsx → REPL.tsx 的初始化
/// </summary>
public static class TerminalApp
{
public static void Run(IHost host)
{
Application.Init();
Application.UseSystemConsole = true;
// 全局主题
ApplyTheme();
// 创建主窗口
var top = Application.Top;
var replScreen = new REPLScreen(host.Services);
top.Add(replScreen);
// 全局键绑定
Application.KeyBindings.Add(Key.Esc, Command.Quit);
Application.KeyBindings.Add(Key.CtrlMask | Key.C, Command.Cancel);
Application.Run(top);
Application.Shutdown();
}
private static void ApplyTheme()
{
var colors = Colors.ColorSchemes["Base"];
colors.Normal = new Attribute(Color.White, Color.Black);
colors.Focus = new Attribute(Color.Green, Color.Black);
colors.HotNormal = new Attribute(Color.BrightCyan, Color.Black);
colors.HotFocus = new Attribute(Color.BrightGreen, Color.Black);
}
}
```
**设计意图**
Terminal.Gui 的 `Application.Init()` 对应 Ink 的 `render()` 调用。`UseSystemConsole = true` 确保在大多数终端模拟器下使用原生控制台输出,避免 Terminal.Gui 的 curses 后端与某些终端的兼容性问题。主题颜色选取刻意与原始 Ink 样式保持一致:白色前景、黑色背景、绿色高亮。
---
## 15.2 REPL 主屏幕
`REPLScreen` 对应原始 `REPL.tsx` 的全部交互逻辑,包括消息流渲染、斜杠命令路由和状态订阅。
```csharp
/// <summary>
/// REPL 主屏幕 — Terminal.Gui 版本
/// 对应原始 REPL.tsx~800 行 React/Ink 组件)
/// </summary>
public sealed class REPLScreen : Window
{
private readonly IQueryEngine _queryEngine;
private readonly IAppStateStore _stateStore;
private readonly ICommandRegistry _commands;
private readonly IBackgroundTaskManager _taskManager;
private readonly TextView _promptInput;
private readonly ListView _messageList;
private readonly StatusBar _statusBar;
private readonly Label _spinner;
private readonly IDisposable _stateSubscription;
public REPLScreen(IServiceProvider services) : base("free-code")
{
_queryEngine = services.GetRequiredService<IQueryEngine>();
_stateStore = services.GetRequiredService<IAppStateStore>();
_commands = services.GetRequiredService<ICommandRegistry>();
_taskManager = services.GetRequiredService<IBackgroundTaskManager>();
// 布局: 上方消息列表 + 下方输入框 + 底部状态栏
var messagesFrame = new FrameView("Messages")
{
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill() - 5
};
_messageList = new ListView()
{
X = 0, Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
};
messagesFrame.Add(_messageList);
_spinner = new Label("●")
{
X = 0, Y = Pos.Bottom(messagesFrame),
Visible = false
};
_promptInput = new TextView()
{
X = 2, Y = Pos.Bottom(messagesFrame),
Width = Dim.Fill(),
Height = 4,
};
_promptInput.KeyPress += OnPromptKeyPress;
_statusBar = new StatusBar()
{
X = 0, Y = Pos.Bottom(_promptInput),
};
Add(messagesFrame, _spinner, _promptInput, _statusBar);
// 订阅状态变更
_stateSubscription = _stateStore.Subscribe(OnStateChanged);
}
private void OnPromptKeyPress(KeyEventEventArgs e)
{
// Enter 提交, Shift+Enter 换行
if (e.KeyEvent.Key == Key.Enter && !e.KeyEvent.IsShift)
{
e.Handled = true;
var input = _promptInput.Text.ToString()?.Trim();
if (string.IsNullOrEmpty(input)) return;
// 斜杠命令检测
if (input.StartsWith('/'))
_ = HandleCommandAsync(input);
else
_ = HandleQueryAsync(input);
_promptInput.Text = "";
}
// Ctrl+C 取消
if (e.KeyEvent.Key == (Key.CtrlMask | Key.C))
{
_ = _queryEngine.CancelAsync();
e.Handled = true;
}
}
private async Task HandleQueryAsync(string input)
{
_spinner.Visible = true;
_spinner.Text = "◉ Thinking...";
try
{
await foreach (var msg in _queryEngine.SubmitMessageAsync(input))
{
switch (msg)
{
case SDKMessage.StreamingDelta delta:
UpdateAssistantMessage(delta.Text);
break;
case SDKMessage.ToolUseStart tus:
AddToolUseMessage(tus.Name, tus.Input);
break;
case SDKMessage.ToolUseResult tur:
UpdateToolResult(tur.ToolUseId, tur.Output);
break;
case SDKMessage.AssistantMessage:
_spinner.Visible = false;
break;
}
}
}
catch (OperationCanceledException)
{
AddSystemMessage("[Cancelled]");
}
_spinner.Visible = false;
}
private async Task HandleCommandAsync(string input)
{
var parts = input.Split(' ', 2);
var commandName = parts[0][1..]; // 去掉 '/'
var args = parts.Length > 1 ? parts[1] : null;
var commands = await _commands.GetEnabledCommandsAsync();
var command = commands.FirstOrDefault(c =>
c.Name == commandName || c.Aliases?.Contains(commandName) == true);
if (command == null)
{
AddSystemMessage($"Unknown command: /{commandName}");
return;
}
var result = await command.ExecuteAsync(
new CommandContext(_stateStore, _queryEngine), args);
if (result.Output != null)
AddSystemMessage(result.Output);
}
private void OnStateChanged(AppState state)
{
// 更新状态栏(须切回主线程)
Application.MainLoop.Invoke(() =>
{
var model = state.MainLoopModelForSession ?? "claude-sonnet-4-6";
var bgTasks = state.Tasks.Values
.Count(t => t.Status is TaskStatus.Running or TaskStatus.Pending);
_statusBar.Text =
$" Model: {model} | Tasks: {bgTasks} | Dir: {Directory.GetCurrentDirectory()}";
});
}
}
```
**设计意图**
原始 `REPL.tsx` 通过 React 状态(`useState`/`useReducer`)驱动 UI 更新。`REPLScreen` 将这套推送模型转换为 `IAppStateStore.Subscribe` 订阅回调,并通过 `Application.MainLoop.Invoke` 确保 UI 变更在主线程执行,与 Ink 的 React reconciler 在语义上等价。
消息流处理通过 `await foreach` 消费 `IQueryEngine.SubmitMessageAsync` 返回的 `IAsyncEnumerable<SDKMessage>`完整保留原始流式渲染语义。Ctrl+C 调用 `_queryEngine.CancelAsync()` 对应原始组件中的 `AbortController` 取消机制。
---
## 15.3 组件库
对应原始 `../../src/components/` 目录中的 50+ React/Ink 组件。以下列出两个核心组件的 .NET 实现。
### PermissionDialog
```csharp
/// <summary>
/// 权限对话框 — 用户审批工具使用
/// 对应原始 PermissionRequest 组件
/// </summary>
public sealed class PermissionDialog : Dialog
{
public PermissionResponse Response { get; private set; }
public PermissionDialog(string toolName, string description, string input)
: base($"Permission: {toolName}", 60, 12)
{
var descLabel = new Label(description) { X = 1, Y = 1, Width = Dim.Fill() - 2 };
var inputLabel = new Label(Truncate(input, 200)) { X = 1, Y = 3, Width = Dim.Fill() - 2 };
var allowBtn = new Button("Allow (y)") { X = 5, Y = 8 };
var denyBtn = new Button("Deny (n)") { X = 25, Y = 8 };
var alwaysBtn = new Button("Always Allow (a)") { X = 40, Y = 8 };
allowBtn.Clicked += () => { Response = PermissionResponse.AllowOnce; RequestStop(); };
denyBtn.Clicked += () => { Response = PermissionResponse.Deny; RequestStop(); };
alwaysBtn.Clicked += () => { Response = PermissionResponse.AllowAlways; RequestStop(); };
Add(descLabel, inputLabel, allowBtn, denyBtn, alwaysBtn);
}
}
```
**设计意图**
原始 `PermissionRequest` 组件通过 React props 回调传递用户选择。`PermissionDialog` 改用 `PermissionResponse` 属性,调用方在 `Application.Run(dialog)` 返回后读取该属性,取得与原始回调语义等价的结果,但完全同步。
### CompanionSpriteView
```csharp
/// <summary>
/// 同伴精灵渲染 — ASCII art sprite
/// 对应原始 CompanionSprite 组件
/// </summary>
public sealed class CompanionSpriteView : View
{
private readonly Companion _companion;
public CompanionSpriteView(Companion companion)
{
_companion = companion;
Width = 20;
Height = 10;
}
public override void Draw()
{
base.Draw();
var sprite = CompanionSpriteRenderer.Render(_companion);
Move(0, 0);
for (var i = 0; i < sprite.Length; i++)
{
var line = sprite[i];
Driver.AddStr(line);
Move(0, i + 1);
}
}
}
```
**设计意图**
原始 `CompanionSprite` 是纯渲染组件,通过 Ink 的 `<Text>` 将 ASCII art 输出到终端。`CompanionSpriteView` 重写 `Draw()` 方法,用 Terminal.Gui 的 `Driver.AddStr` 逐行写入,保持与原始输出完全相同的视觉效果。`CompanionSpriteRenderer.Render` 静态方法封装精灵图案生成逻辑,独立于 UI 层测试。
---
## 组件映射一览
| 原始 Ink 组件 | .NET Terminal.Gui 实现 | 说明 |
|---|---|---|
| `REPL.tsx` | `REPLScreen : Window` | 主屏幕 |
| `PermissionRequest` | `PermissionDialog : Dialog` | 工具审批对话框 |
| `CompanionSprite` | `CompanionSpriteView : View` | ASCII 精灵渲染 |
| `StatusBar` | `StatusBar`Terminal.Gui 内置)| 底部状态栏 |
| `Spinner` | `Label`(动态文本)| 思考中指示器 |
| `MessageList` | `ListView` | 消息历史列表 |
| `PromptInput` | `TextView` | 多行输入框 |
其余 43+ 组件(`FileDiff``TokenBudget``HistoryPicker` 等)由对应的特性开关控制加载,在各自模块文档中描述。
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [UI 与扩展设计 — 特性开关系统](UI与扩展设计-特性开关系统.md)

View File

@ -0,0 +1,265 @@
# 技能系统
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `../../src/skills/`
---
## 概述
技能系统允许用户以 Markdown 文件的形式定义可复用的指令集,通过斜杠命令或提示注入的方式触发。原始实现扫描 `.free-code/skills/` 目录,解析每个文件的 YAML frontmatter 获取元数据,并将 Markdown 正文作为 System Prompt 内容注入查询引擎。
.NET 重写通过 `ISkillLoader` 接口封装这一流程,用 `SkillDefinition` record 描述单条技能,用 `SkillHooks` record 描述生命周期钩子。`SkillLoader` 是默认实现负责目录扫描、YAML 解析、结果缓存和执行委托。
---
## 16.1 接口与数据模型
```csharp
/// <summary>
/// 技能加载器 — 从 .free-code/skills/ 目录加载 SKILL.md
/// 对应原始 skill system 的目录扫描 + frontmatter 解析
/// </summary>
public interface ISkillLoader
{
Task<IReadOnlyList<SkillDefinition>> LoadAllSkillsAsync();
Task<SkillDefinition?> LoadSkillAsync(string skillName);
Task ExecuteSkillAsync(SkillDefinition skill, string? args);
}
```
### SkillDefinition
```csharp
/// <summary>
/// 技能定义 — 对应一个 SKILL.md 文件的完整解析结果
/// </summary>
public sealed record SkillDefinition
{
/// <summary>技能名称,来自 frontmatter 或文件名</summary>
public required string Name { get; init; }
/// <summary>技能描述,用于斜杠命令帮助文本</summary>
public string? Description { get; init; }
/// <summary>Markdown 正文,作为 System Prompt 注入</summary>
public required string Content { get; init; }
/// <summary>技能允许使用的工具名称列表</summary>
public IReadOnlyList<string> Tools { get; init; } = [];
/// <summary>覆盖默认模型(如 "claude-haiku-4-5"</summary>
public string? Model { get; init; }
/// <summary>技能参数定义(参数名 → 描述)</summary>
public IReadOnlyDictionary<string, string>? Arguments { get; init; }
/// <summary>生命周期钩子</summary>
public SkillHooks? Hooks { get; init; }
/// <summary>磁盘路径,仅供调试</summary>
public string? FilePath { get; init; }
}
```
### SkillHooks
```csharp
/// <summary>
/// 技能生命周期钩子
/// 对应原始 skill hooks 机制
/// </summary>
public sealed record SkillHooks
{
/// <summary>执行前的提示注入片段</summary>
public string? PreExecute { get; init; }
/// <summary>执行后的提示注入片段</summary>
public string? PostExecute { get; init; }
/// <summary>发生错误时的提示注入片段</summary>
public string? OnError { get; init; }
}
```
**设计意图**
`SkillDefinition` 使用 `sealed record` 确保不可变性:一旦从磁盘解析完成,技能定义在整个会话生命周期内不会改变。`Tools` 字段对应原始 frontmatter 中的 `tools` 数组,允许技能声明自己需要哪些工具,`ToolRegistry` 在组装工具池时可据此过滤。
---
## 16.2 SkillLoader 实现
```csharp
public sealed class SkillLoader : ISkillLoader
{
private readonly string[] _skillDirectories;
private List<SkillDefinition>? _cached;
private readonly ILogger<SkillLoader> _logger;
public SkillLoader(ILogger<SkillLoader> logger)
{
_logger = logger;
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var cwd = Directory.GetCurrentDirectory();
// 扫描顺序: 项目级 → 用户级
_skillDirectories =
[
Path.Combine(cwd, ".free-code", "skills"),
Path.Combine(home, ".free-code", "skills"),
];
}
public async Task<IReadOnlyList<SkillDefinition>> LoadAllSkillsAsync()
{
if (_cached != null) return _cached;
_cached = [];
foreach (var dir in _skillDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.md", SearchOption.AllDirectories))
{
try
{
var skill = await ParseSkillFileAsync(file);
if (skill != null) _cached.Add(skill);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load skill from {File}", file);
}
}
}
return _cached;
}
public async Task<SkillDefinition?> LoadSkillAsync(string skillName)
{
var all = await LoadAllSkillsAsync();
return all.FirstOrDefault(s =>
string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 解析 SKILL.md — YAML frontmatter + Markdown body
/// </summary>
private static async Task<SkillDefinition?> ParseSkillFileAsync(string filePath)
{
var content = await File.ReadAllTextAsync(filePath);
if (!content.StartsWith("---")) return null;
var endIdx = content.IndexOf("\n---", 3);
if (endIdx < 0) return null;
var frontmatter = content[3..endIdx];
var body = content[(endIdx + 4)..].TrimStart();
// 解析 YAML frontmatter
var deserializer = new YamlDotNet.Serialization.DeserializerBuilder().Build();
var meta = deserializer.Deserialize<Dictionary<string, object>>(frontmatter);
return new SkillDefinition
{
Name = meta.GetValueOrDefault("name",
Path.GetFileNameWithoutExtension(filePath))?.ToString() ?? "",
Description = meta.GetValueOrDefault("description")?.ToString(),
Content = body,
Tools = (meta.GetValueOrDefault("tools") as List<object>)
?.Select(o => o.ToString() ?? "")
.ToList() ?? [],
Model = meta.GetValueOrDefault("model")?.ToString(),
FilePath = filePath,
};
}
public async Task ExecuteSkillAsync(SkillDefinition skill, string? args)
{
// 技能内容注入 System Prompt参数拼接到末尾由 QueryEngine 执行
var fullPrompt = skill.Content;
if (args != null)
fullPrompt += $"\n\nArguments: {args}";
// 通过 QueryEngine 提交,渲染到 UI
var engine = /* 从 IServiceProvider 解析 */;
await foreach (var msg in engine.SubmitMessageAsync(fullPrompt))
{
// UI 渲染逻辑
}
}
}
```
---
## 目录扫描与优先级
`SkillLoader` 按以下顺序扫描两个目录,先找到的技能名称优先:
| 优先级 | 路径 | 说明 |
|---|---|---|
| 1| `<当前工作目录>/.free-code/skills/` | 项目级技能,随代码库版本管理 |
| 2| `~/.free-code/skills/` | 用户级技能,跨项目共享 |
同名技能以项目级为准,允许项目级覆盖用户级的默认行为。
---
## SKILL.md 文件格式
```markdown
---
name: code-review
description: 对当前分支的变更进行代码审查
tools:
- Bash
- Read
model: claude-sonnet-4-6
---
你是一位严谨的代码审查员。分析提供的代码变更,重点关注:
- 潜在的 bug 和边界条件
- 安全漏洞
- 性能问题
- 代码风格与项目规范的一致性
输出格式:按严重性分组,每条问题标注文件和行号。
```
frontmatter 字段说明:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `name` | `string` | 否 | 技能名称,默认使用文件名 |
| `description` | `string` | 否 | 显示在斜杠命令帮助中的描述 |
| `tools` | `string[]` | 否 | 允许使用的工具,空则使用全部 |
| `model` | `string` | 否 | 覆盖默认模型 |
| `arguments` | `object` | 否 | 参数定义(名称 → 描述) |
---
## 生命周期钩子执行顺序
```
ExecuteSkillAsync(skill, args)
├── 1. hooks.PreExecute → 注入到提示前缀
├── 2. skill.Content → 主提示内容
├── 3. args → 用户传入参数
└── QueryEngine.SubmitMessageAsync(fullPrompt)
└── [成功] hooks.PostExecute
└── [失败] hooks.OnError
```
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [UI 与扩展设计 — 插件系统](UI与扩展设计-插件系统.md)

View File

@ -0,0 +1,280 @@
# 插件系统
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `../../src/plugins/`
---
## 概述
插件系统允许第三方代码在运行时扩展 free-code 的能力,包括注册新技能、新斜杠命令和新 MCP 服务器配置。原始实现通过 Node.js 的动态 `import()` 加载插件模块,利用模块缓存隔离各插件的命名空间。
.NET 重写用 `AssemblyLoadContext`ALC替代动态导入实现更强的隔离性和确定性卸载。每个插件运行在独立的 `PluginLoadContext` 实例中,其依赖项从插件目录优先解析,不与宿主进程或其他插件共享程序集版本。`PluginManager` 管理所有插件的生命周期,支持安装、卸载、启用和禁用操作。
---
## 17.1 接口定义
```csharp
/// <summary>
/// 插件管理器 — AssemblyLoadContext 隔离加载
/// 对应原始 plugin system
/// </summary>
public interface IPluginManager
{
Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync();
Task InstallPluginAsync(string pluginId);
Task UninstallPluginAsync(string pluginId);
Task EnablePluginAsync(string pluginId);
Task DisablePluginAsync(string pluginId);
Task RefreshAsync();
}
```
---
## 17.2 LoadedPlugin 与 PluginManifest
```csharp
/// <summary>
/// 已加载的插件实例描述
/// </summary>
public sealed record LoadedPlugin
{
/// <summary>插件唯一标识符(目录名)</summary>
public required string Id { get; init; }
/// <summary>插件显示名称</summary>
public required string Name { get; init; }
/// <summary>版本字符串</summary>
public required string Version { get; init; }
/// <summary>来源marketplace | local | github</summary>
public required string Source { get; init; }
/// <summary>主程序集路径</summary>
public required string AssemblyPath { get; init; }
/// <summary>是否启用</summary>
public bool IsEnabled { get; init; }
/// <summary>插件清单</summary>
public PluginManifest Manifest { get; init; } = new();
}
/// <summary>
/// 插件清单 — plugin.json 反序列化目标
/// 描述插件向宿主暴露的所有扩展点
/// </summary>
public sealed record PluginManifest
{
/// <summary>插件提供的技能列表</summary>
public IReadOnlyList<SkillDefinition> Skills { get; init; } = [];
/// <summary>插件提供的斜杠命令列表</summary>
public IReadOnlyList<ICommand> Commands { get; init; } = [];
/// <summary>插件注册的 MCP 服务器配置(服务器名 → 配置)</summary>
public IReadOnlyDictionary<string, ScopedMcpServerConfig> McpServers { get; init; }
= new Dictionary<string, ScopedMcpServerConfig>();
}
```
**设计意图**
`PluginManifest` 直接对应 `plugin.json` 的顶层结构三个扩展点技能、命令、MCP 服务器)分别映射为 `SkillLoader``CommandRegistry``McpClientManager` 的动态注册入口。插件卸载时,这三个注册表各自移除该插件贡献的条目。
---
## 17.3 PluginLoadContext
```csharp
/// <summary>
/// 可卸载的程序集加载上下文
/// 对应原始动态 import() 的隔离语义
/// </summary>
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 优先从插件目录解析,回退到默认加载上下文
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null
? LoadFromAssemblyPath(assemblyPath)
: null; // null 触发回退到默认 ALC
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return libraryPath != null
? LoadUnmanagedDllFromPath(libraryPath)
: IntPtr.Zero;
}
}
```
**设计意图**
`isCollectible: true` 是关键参数。普通 `AssemblyLoadContext` 加载的程序集在整个进程生命周期内无法卸载。可回收 ALC 允许在所有对该上下文中类型的强引用归零后GC 完整回收其托管内存和元数据,这是 `/plugin uninstall` 命令实现热卸载的基础。
`AssemblyDependencyResolver` 读取插件主程序集旁边的 `.deps.json` 文件,自动解析所有传递依赖的路径。插件作者只需将所有依赖打包到插件目录,无需关心宿主进程中已有哪些程序集版本。
---
## 17.4 PluginManager 实现
```csharp
public sealed class PluginManager : IPluginManager
{
private readonly ConcurrentDictionary<string, PluginLoadContext> _contexts = new();
private readonly ConcurrentDictionary<string, LoadedPlugin> _plugins = new();
public async Task<IReadOnlyList<LoadedPlugin>> LoadAllPluginsAsync()
{
var pluginDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".free-code", "plugins");
if (!Directory.Exists(pluginDir)) return [];
foreach (var dir in Directory.GetDirectories(pluginDir))
{
var manifestPath = Path.Combine(dir, "plugin.json");
if (!File.Exists(manifestPath)) continue;
var manifest = JsonSerializer.Deserialize<PluginManifest>(
await File.ReadAllTextAsync(manifestPath));
var dllPath = Directory.GetFiles(dir, "*.dll").FirstOrDefault();
if (dllPath == null) continue;
var context = new PluginLoadContext(dllPath);
var assembly = context.LoadFromAssemblyPath(dllPath);
_contexts[dir] = context;
_plugins[dir] = new LoadedPlugin
{
Id = Path.GetFileName(dir),
Name = manifest?.Skills.FirstOrDefault()?.Name
?? Path.GetFileName(dir),
Version = "1.0",
Source = "local",
AssemblyPath = dllPath,
IsEnabled = true,
};
}
return _plugins.Values.ToList();
}
public async Task UninstallPluginAsync(string pluginId)
{
if (_contexts.TryRemove(pluginId, out var context))
{
context.Unload(); // 触发 GC 可回收流程
}
_plugins.TryRemove(pluginId, out _);
}
public Task RefreshAsync()
{
// 重新扫描目录,加载新插件,卸载已删除的
return LoadAllPluginsAsync();
}
public Task InstallPluginAsync(string pluginId)
{
// 从 marketplace / GitHub 下载到 ~/.free-code/plugins/<id>/
// 然后调用 LoadAllPluginsAsync 触发加载
throw new NotImplementedException("待实现marketplace 下载逻辑");
}
public Task EnablePluginAsync(string pluginId)
{
if (_plugins.TryGetValue(pluginId, out var plugin))
_plugins[pluginId] = plugin with { IsEnabled = true };
return Task.CompletedTask;
}
public Task DisablePluginAsync(string pluginId)
{
if (_plugins.TryGetValue(pluginId, out var plugin))
_plugins[pluginId] = plugin with { IsEnabled = false };
return Task.CompletedTask;
}
}
```
---
## 插件目录结构
```
~/.free-code/plugins/
└── my-plugin/
├── plugin.json # 插件清单
├── MyPlugin.dll # 主程序集
├── MyPlugin.deps.json # 依赖清单(供 AssemblyDependencyResolver 使用)
└── SomeDependency.dll # 插件私有依赖
```
### plugin.json 示例
```json
{
"skills": [
{
"name": "my-skill",
"description": "自定义技能示例",
"content": "你是一个帮助用户完成特定任务的助手。"
}
],
"commands": [],
"mcpServers": {
"my-mcp": {
"command": "npx",
"args": ["-y", "my-mcp-server"],
"scope": "local"
}
}
}
```
---
## 生命周期图
```
LoadAllPluginsAsync()
├── 扫描 ~/.free-code/plugins/ 目录
├── 读取 plugin.json → 反序列化 PluginManifest
├── 创建 PluginLoadContext(dllPath)
├── LoadFromAssemblyPath(dllPath)
└── 注册到 _plugins / _contexts
UninstallPluginAsync(id)
├── context.Unload() [ALC 标记为待回收]
├── 移除 _contexts[id] [WeakReference 归零后 GC 回收]
└── 移除 _plugins[id] [插件对象消失]
```
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [UI 与扩展设计 — 技能系统](UI与扩展设计-技能系统.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md)

View File

@ -0,0 +1,270 @@
# 特性开关系统
> 所属项目: free-code .NET 10 重写
> 所属模块: [UI 与扩展设计](UI与扩展设计.md)
> 原始代码: `scripts/build.ts` `feature()` 编译时开关 + 运行时 GrowthBook
---
## 概述
特性开关系统控制 free-code 的 88 个功能标志,其中 54 个可编译、34 个损坏。原始实现有两个层次:编译时用 `scripts/build.ts``feature()` 函数注入 `#define`,运行时用 GrowthBook SDK 从云端拉取实验配置。
.NET 重写将这两个层次统一到同一套接口下。`FeatureFlags` 静态类持有全部 54 个常量,供编译时 `#if` 和运行时查询共同使用;`IFeatureFlagService` 抽象运行时查询;`FeatureFlagService``appsettings.json``FeatureFlags` 配置节读取初始值,取代原始 GrowthBook 的云端下发机制,确保零网络依赖。
---
## 18.1 FeatureFlags 常量清单
```csharp
/// <summary>
/// 特性开关常量 — 编译时 #if + 运行时查询
/// 对应原始 88 个 feature flags54 可编译34 损坏)
/// </summary>
public static class FeatureFlags
{
// ===== 交互与 UI =====
public const string Ultraplan = "ULTRAPLAN";
public const string Ultrathink = "ULTRATHINK";
public const string VoiceMode = "VOICE_MODE";
public const string TokenBudget = "TOKEN_BUDGET";
public const string HistoryPicker = "HISTORY_PICKER";
public const string MessageActions = "MESSAGE_ACTIONS";
public const string QuickSearch = "QUICK_SEARCH";
public const string ShotStats = "SHOT_STATS";
// ===== Agent、记忆与规划 =====
public const string BuiltinExplorePlanAgents = "BUILTIN_EXPLORE_PLAN_AGENTS";
public const string VerificationAgent = "VERIFICATION_AGENT";
public const string AgentTriggers = "AGENT_TRIGGERS";
public const string AgentTriggersRemote = "AGENT_TRIGGERS_REMOTE";
public const string ExtractMemories = "EXTRACT_MEMORIES";
public const string CompactionReminders = "COMPACTION_REMINDERS";
public const string CachedMicrocompact = "CACHED_MICROCOMPACT";
public const string TeamMem = "TEAMMEM";
// ===== 工具与基础设施 =====
public const string BridgeMode = "BRIDGE_MODE";
public const string BashClassifier = "BASH_CLASSIFIER";
public const string PromptCacheBreakDetection = "PROMPT_CACHE_BREAK_DETECTION";
// ===== 其余 35 个可编译标志 =====
public const string Buddy = "BUDDY";
public const string V2Todo = "V2_TODO";
public const string EnableLspTool = "ENABLE_LSP_TOOL";
public const string ContextCollapse = "CONTEXT_COLLAPSE";
public const string TokenBudgetWarning = "TOKEN_BUDGET_WARNING";
public const string MemoryAutoExtract = "MEMORY_AUTO_EXTRACT";
public const string CompactHistory = "COMPACT_HISTORY";
public const string PermissionClassifier = "PERMISSION_CLASSIFIER";
public const string AsyncToolExecution = "ASYNC_TOOL_EXECUTION";
public const string Speculation = "SPECULATION";
public const string PromptSuggestion = "PROMPT_SUGGESTION";
public const string SkillImprovement = "SKILL_IMPROVEMENT";
public const string AgentSwarm = "ENABLE_AGENT_SWARMS";
public const string WorkflowTool = "WORKFLOW_TOOL";
public const string MonitorTool = "MONITOR_TOOL";
public const string McpSkills = "MCP_SKILLS";
public const string ChicagoMcp = "CHICAGO_MCP";
public const string EnableDeskApi = "ENABLE_DESK_API";
public const string Heapdump = "HEAPDUMP";
public const string EffortFlag = "EFFORT";
public const string Advisor = "ADVISOR";
public const string ThinkingBudget = "THINKING_BUDGET";
public const string RemoteEnv = "REMOTE_ENV";
public const string NotifyTool = "NOTIFY_TOOL";
public const string CaptureTool = "CAPTURE_TOOL";
public const string ReplayUserMessages = "REPLAY_USER_MESSAGES";
public const string TelemetryFlush = "TELEMETRY_FLUSH";
public const string StripUnusedImports = "STRIP_UNUSED_IMPORTS";
public const string ToolResultStorage = "TOOL_RESULT_STORAGE";
public const string McpStreaming = "MCP_STREAMING";
public const string PlanMode = "PLAN_MODE";
public const string PlanModeV2 = "PLAN_MODE_V2";
public const string RemoteControl = "REMOTE_CONTROL";
public const string ConsentedRemoteControl = "CONSENTED_REMOTE_CONTROL";
public const string CcrV2 = "CCR_V2";
public const string EnableSandbox = "ENABLE_SANDBOX";
public const string DiffBasedEdit = "DIFF_BASED_EDIT";
public const string OutputTruncation = "OUTPUT_TRUNCATION";
public const string WebSearchTool = "WEB_SEARCH_TOOL";
public const string PythonTool = "PYTHON_TOOL";
public const string NotebookEditTool = "NOTEBOOK_EDIT_TOOL";
public const string DedupeToolCalls = "DEDUPE_TOOL_CALLS";
public const string IdeIntegration = "IDE_INTEGRATION";
public const string RateLimitUi = "RATE_LIMIT_UI";
public const string TokenCounting = "TOKEN_COUNTING";
}
```
---
## 标志分类一览
| 类别 | 标志数量 | 说明 |
|---|---|---|
| 交互与 UI | 8 | 提示词增强、历史记录、语音模式等 |
| Agent、记忆与规划 | 8 | 多 Agent、记忆提取、规划模式等 |
| 工具与基础设施 | 3 | IDE 桥接、分类器、缓存检测 |
| 其余可编译标志 | 35 | 各类实验功能 |
| **合计可编译** | **54** | |
| 损坏标志(不含)| 34 | 需重建,详见 FEATURES.md |
---
## 18.2 IFeatureFlagService 接口
```csharp
/// <summary>
/// 运行时特性开关查询接口
/// </summary>
public interface IFeatureFlagService
{
/// <summary>查询指定标志是否启用</summary>
bool IsEnabled(string flag);
/// <summary>动态设置标志状态(用于测试或运行时覆盖)</summary>
void SetFlag(string flag, bool enabled);
}
```
---
## 18.3 FeatureFlagService 实现
```csharp
/// <summary>
/// 运行时特性开关实现
/// 编译时使用 #if FLAG_NAME运行时使用 IsEnabled()
/// 对应原始 GrowthBook SDK 的本地替代
/// </summary>
public sealed class FeatureFlagService : IFeatureFlagService
{
private readonly ConcurrentDictionary<string, bool> _flags = new();
public FeatureFlagService(IConfiguration config)
{
// 从 appsettings.json 的 FeatureFlags 节加载
var section = config.GetSection("FeatureFlags");
foreach (var child in section.GetChildren())
_flags[child.Key] = bool.Parse(child.Value ?? "false");
}
public bool IsEnabled(string flag)
=> _flags.GetValueOrDefault(flag, false);
public void SetFlag(string flag, bool enabled)
=> _flags[flag] = enabled;
}
```
---
## 双模式设计
特性开关系统同时支持两种工作模式,对应原始实现的两个不同层次。
### 编译时模式(`#if`
```csharp
// 编译时完全消除死代码,减小二进制体积
#if ULTRAPLAN
services.AddSingleton<IUltraplanService, UltraplanService>();
#endif
#if VOICE_MODE
services.AddSingleton<IVoiceService, VoiceService>();
#endif
```
编译时标志通过 MSBuild 属性注入,对应原始 `build.ts``feature()` 函数:
```xml
<!-- Directory.Build.props -->
<PropertyGroup Condition="'$(FEATURE_ULTRAPLAN)' == 'true'">
<DefineConstants>$(DefineConstants);ULTRAPLAN</DefineConstants>
</PropertyGroup>
```
构建命令示例:
```bash
# 全功能构建(对应原始 build:dev:full
dotnet build -p:FEATURE_ULTRAPLAN=true -p:FEATURE_VOICE_MODE=true ...
# 仅启用语音模式(对应原始默认构建)
dotnet build -p:FEATURE_VOICE_MODE=true
```
### 运行时模式(`IsEnabled()`
```csharp
// 不重新编译就能切换,由配置文件驱动
if (_features.IsEnabled(FeatureFlags.TokenBudget))
{
// 显示 token 预算组件
}
if (_features.IsEnabled(FeatureFlags.ExtractMemories))
{
await _memoryService.TryExtractAsync(messages);
}
```
`appsettings.json` 配置示例:
```json
{
"FeatureFlags": {
"TOKEN_BUDGET": true,
"HISTORY_PICKER": true,
"EXTRACT_MEMORIES": false,
"ULTRAPLAN": false
}
}
```
---
## 原始实现对比
| 原始TypeScript| .NET 重写 | 说明 |
|---|---|---|
| `scripts/build.ts` `feature("FLAG")` | MSBuild `-p:FEATURE_FLAG=true` | 编译时开关 |
| `#if defined(FLAG)` in bundled output | `#if FLAG_NAME` in C# | 死代码消除 |
| GrowthBook SDK云端下发| `FeatureFlagService`(配置文件)| 运行时开关 |
| `growthbook.isOn("flag-name")` | `_features.IsEnabled(FeatureFlags.Xxx)` | 查询 API |
| 34 损坏标志(缺少资源/类型)| 保留常量,实现体标记 TODO | 向后兼容占位 |
**关键差异**.NET 重写移除了 GrowthBook 的网络依赖。原始实现在每次启动时联系 GrowthBook 服务器更新标志状态,这是一个隐式的遥测上报渠道。`FeatureFlagService` 完全本地化,配置通过 `appsettings.json` 或环境变量覆盖,无任何出站请求。
---
## DI 注册
```csharp
// Program.cs 或服务注册扩展方法
services.AddSingleton<IFeatureFlagService, FeatureFlagService>();
// 使用示例(构造函数注入)
public sealed class SomeService(IFeatureFlagService features)
{
public void DoWork()
{
if (features.IsEnabled(FeatureFlags.AsyncToolExecution))
{
// 异步工具执行路径
}
}
}
```
---
## 参考资料
- [UI 与扩展设计 — 总览](UI与扩展设计.md)
- [UI 与扩展设计 — Terminal.Gui 终端 UI](UI与扩展设计-Terminal-Gui终端UI.md)
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [测试与构建](../测试与构建/)
- [FEATURES.md](../../../FEATURES.md)(完整 88 个标志审计)

View File

@ -0,0 +1,95 @@
# UI 与扩展设计 — 总览
> 所属项目: free-code .NET 10 重写
> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [基础设施设计](../基础设施设计/) | [服务子系统设计](../服务子系统设计/)
---
## 概述
UI 与扩展设计覆盖 free-code .NET 10 重写中所有面向用户的终端界面层,以及三个扩展机制:技能、插件和特性开关。
原始 TypeScript 实现以 React/Ink 渲染终端 UI用文件系统约定管理技能用动态 `import()` 加载插件,用编译时 `feature()` 函数控制特性开关。.NET 重写将这四个关注点各自封装为独立模块Terminal.Gui 替代 Ink 提供声明式终端 UI`ISkillLoader` 替代目录扫描约定,`AssemblyLoadContext` 替代动态导入实现插件隔离,`IFeatureFlagService` 在运行时桥接编译时标志与配置驱动开关。
---
## 架构概览
```
REPLScreen (Terminal.Gui Window)
├── PermissionDialog 工具使用审批
├── CompanionSpriteView ASCII 精灵渲染
├── ISkillLoader 技能加载与执行
│ └── SkillDefinition + SkillHooks
├── IPluginManager 插件生命周期管理
│ └── PluginLoadContext (AssemblyLoadContext)
└── IFeatureFlagService 特性开关查询
└── FeatureFlags (54+ 常量)
```
---
## 子模块列表
### [Terminal.Gui 终端 UI](UI与扩展设计-Terminal-Gui终端UI.md)
覆盖 `TerminalApp` 应用启动与主题配置、`REPLScreen` 主交互屏幕的完整实现,以及 `PermissionDialog``CompanionSpriteView` 组件。
原始来源: `../../src/screens/REPL.tsx`, `../../src/components/`50+ React/Ink 组件)
---
### [技能系统](UI与扩展设计-技能系统.md)
覆盖 `ISkillLoader` 接口、`SkillDefinition` 数据模型、`SkillHooks` 生命周期钩子,以及 `SkillLoader` 的目录扫描与 YAML frontmatter 解析实现。
原始来源: `../../src/skills/`
---
### [插件系统](UI与扩展设计-插件系统.md)
覆盖 `IPluginManager` 接口、`PluginLoadContext` 可卸载程序集加载上下文、`PluginManager` 的完整实现,以及 `PluginManifest` 清单结构。
原始来源: `../../src/plugins/`
---
### [特性开关系统](UI与扩展设计-特性开关系统.md)
覆盖 `FeatureFlags` 完整常量清单54+ 标志)、`IFeatureFlagService` 接口,以及 `FeatureFlagService` 的配置驱动实现。说明编译时 `#if` 与运行时 `IsEnabled()` 的双模式设计。
原始来源: `scripts/build.ts` `feature()` 编译时开关 + 运行时 GrowthBook
---
## 关键设计决策
**Terminal.Gui 替代 React/Ink**
原始项目深度依赖 React 组件模型和 Ink 的虚拟 DOM 终端渲染。.NET 重写选用 Terminal.Gui它提供与 Ink 语义最接近的声明式布局(`Pos`/`Dim`)、事件驱动模型和色彩主题系统,同时完全原生于 .NET 运行时,无需 Node.js 桥接。
**技能作为 System Prompt 注入**
技能系统不引入自定义执行运行时。技能的 Markdown 内容直接注入 `QueryEngine` 的 System Prompt参数拼接到提示末尾其余执行逻辑全部复用主查询管道。这与原始实现的语义完全一致且不新增任何抽象层。
**插件隔离通过 `isCollectible: true`**
`PluginLoadContext``isCollectible: true` 创建,允许在 `UninstallPluginAsync` 调用后通过 GC 完整回收插件的所有托管内存和类型元数据。这在语义上等价于原始 TypeScript 的动态 `import()` 隔离,但提供更强的确定性卸载保证。
**特性开关双模式并存**
编译时标志(`#if ULTRAPLAN`)用于完全消除死代码,减小最终二进制体积。运行时标志(`IFeatureFlagService.IsEnabled`)用于不重新编译就能切换的开关,由 `appsettings.json` 中的 `FeatureFlags` 配置节驱动,取代原始 GrowthBook 的云端下发机制。
---
## 参考资料
- [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md)
- [服务子系统设计](../服务子系统设计/)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)

View File

@ -0,0 +1,30 @@
# 原始代码映射UI 与扩展设计
> 交叉引用详见《UI 与扩展设计》总览文档中的模块分层、命名约定与扩展边界说明。
## .NET 类型 → 原始 TypeScript 源文件映射
| .NET 类型/模块 | 原始 TypeScript 源文件 | 对应关系说明 |
|---|---|---|
| TerminalApp | `../../../src/screens/REPL.tsx` + `../../../src/components/` | 对应终端主入口与交互循环UI 逻辑主要集中在 REPL 屏幕,配套的 Ink/React 组件负责局部渲染与交互。 |
| REPLScreen | `../../../src/screens/REPL.tsx` | 直接对应 REPL 主屏幕实现,是终端 UI 的核心承载层。 |
| PermissionDialog | `../../../src/components/` | 对应权限确认类对话框组件,属于终端 UI 中的交互式弹窗/提示层。 |
| CompanionSpriteView | `../../../src/components/` | 对应陪伴角色/装饰性展示组件,通常作为界面中的独立视图单元实现。 |
| ISkillLoader | `../../../src/skills/` | 对应技能加载抽象接口,用于定义技能发现、解析与装载能力。 |
| SkillDefinition | `../../../src/skills/` | 对应技能定义模型,承载技能元数据、入口与执行相关描述。 |
| SkillHooks | `../../../src/skills/` | 对应技能生命周期钩子集合,用于扩展技能执行流程。 |
| SkillLoader | `../../../src/skills/` | 对应技能加载器实现,负责读取并实例化技能配置。 |
| IPluginManager | `../../../src/plugins/` | 对应插件管理抽象接口,定义插件注册、启用与调度能力。 |
| PluginLoadContext | `../../../src/plugins/` | 对应插件加载上下文,封装插件初始化与运行时依赖。 |
| PluginManager | `../../../src/plugins/` | 对应插件管理器实现,负责插件生命周期管理。 |
| LoadedPlugin | `../../../src/plugins/` | 对应已加载插件实例模型,表示运行时插件对象。 |
| PluginManifest | `../../../src/plugins/` | 对应插件清单/描述文件模型,保存插件名称、版本、入口等信息。 |
| FeatureFlags | `scripts/build.ts``feature()` + 运行时 GrowthBook | 对应构建期特性开关与运行时实验能力的统一抽象。 |
| IFeatureFlagService | `scripts/build.ts``feature()` + 运行时 GrowthBook | 对应特性开关服务接口,既覆盖编译期注入,也覆盖运行时读取。 |
| FeatureFlagService | `scripts/build.ts``feature()` + 运行时 GrowthBook | 对应特性开关服务实现,负责聚合构建时与运行时的开关结果。 |
## .NET 命名约定说明
- `.NET 类型/模块` 列优先使用 C# 语义化命名,表示面向对象层的抽象、服务或模型。
- `原始 TypeScript 源文件` 列仅记录最接近的源代码位置;当实现分散时,用 `+` 标明多个来源。
- `对应关系说明` 以职责映射为准,不强求 1:1 文件对应,便于后续从 TypeScript 迁移到 .NET 结构时保持概念一致。

View File

@ -0,0 +1,968 @@
# free-code 项目结构完整分析报告
## 一、项目概览
| 项目 | 详情 |
|---|---|
| **名称** | free-code (Claude Code 的开源重建版本) |
| **版本** | 2.1.87 |
| **来源** | Anthropic Claude Code CLI 的源码快照2026-03-31 npm 分发包 source map 暴露后重建) |
| **运行时** | Bun >= 1.3.11 |
| **语言** | TypeScript (ESNext, JSX: react-jsx) |
| **总代码量** | ~1,997 个 TS/TSX 文件,共 ~512,834 行代码 |
| **核心框架** | React 19 + Ink 6 (终端 UI) |
### 三大修改方向
1. **遥测移除** — 所有 OpenTelemetry/gRPC、GrowthBook 分析、Sentry 报告等外发遥测全部替换为空实现stub
2. **安全提示护栏移除** — 移除了 Anthropic 注入的额外限制性系统提示
3. **实验性功能解锁** — 88 个 feature flag 中 54 个可编译通过并启用
---
## 二、目录结构与代码量
| 目录 | 文件数 | 代码行数 | 职责 |
|---|---|---|---|
| `../src/utils/` | 577 | 178,924 | 工具函数(模型管理、认证、设置、权限等) |
| `../src/components/` | 400 | 81,808 | Ink/React 终端 UI 组件 |
| `../src/services/` | 147 | 54,351 | API 客户端、OAuth、MCP、分析已 stub |
| `../src/tools/` | 210 | 51,068 | 40+ Agent 工具实现 |
| `../src/commands/` | 218 | 26,526 | 70+ 斜杠命令 |
| `../src/ink/` | 98 | 19,844 | Ink 框架内部(终端渲染引擎) |
| `../src/hooks/` | 104 | 19,205 | React hooks |
| `../src/bridge/` | 32 | 12,614 | IDE 远程控制桥接 |
| `../src/cli/` | 21 | 12,408 | CLI 参数解析和命令分发 |
| `../src/screens/` | 3 | 5,981 | 主界面 REPL |
| `../src/entrypoints/` | 11 | 4,162 | 入口点 |
| `../src/skills/` | 50 | 4,092 | 技能系统 |
| `../src/native-ts/` | 4 | 4,081 | 原生模块绑定 |
| `../src/types/` | 12 | 3,468 | 类型定义 |
| `../src/tasks/` | 14 | 3,288 | 后台任务管理 |
| `../src/keybindings/` | 14 | 3,159 | 快捷键配置 |
| `../src/constants/` | 22 | 2,725 | 常量定义 |
| 其他 (18 个目录) | — | — | vim、状态、迁移、MCP 等 |
### 完整目录列表
```
../src/
assistant/ # 助手会话管理
bootstrap/ # 启动引导
bridge/ # IDE 远程控制桥接32 文件)
buddy/ # Buddy 功能
cli/ # CLI 参数解析和快速路径分发21 文件)
commands/ # 70+ 斜杠命令实现218 文件)
components/ # Ink/React 终端 UI 组件400 文件)
constants/ # 常量定义22 文件)
context/ # React 上下文
coordinator/ # 协调器模式
daemon/ # 后台守护进程
entrypoints/ # 入口点11 文件)
environment-runner/ # BYOC 环境运行器
hooks/ # React hooks104 文件)
ink/ # Ink 终端渲染引擎98 文件)
jobs/ # 作业系统
keybindings/ # 快捷键配置14 文件)
memdir/ # 记忆目录管理
migrations/ # 数据迁移11 文件)
moreright/ # 扩展权限
native-ts/ # 原生 TypeScript 模块4 文件)
outputStyles/ # 输出样式
plugins/ # 插件系统2 文件)
proactive/ # 主动任务
query/ # 查询管道4 文件)
remote/ # 远程会话管理4 文件)
schemas/ # JSON Schema
screens/ # 主界面屏幕3 文件)
self-hosted-runner/ # 自托管运行器
server/ # 服务器模式11 文件)
services/ # 服务层147 文件)
skills/ # 技能系统50 文件)
ssh/ # SSH 远程会话
state/ # 应用状态管理6 文件)
tasks/ # 后台任务类型14 文件)
tools/ # Agent 工具实现210 文件)
types/ # 类型定义12 文件)
upstreamproxy/ # 上游代理
utils/ # 工具函数577 文件)
vendor/ # 第三方内联代码
vim/ # Vim 编辑模式5 文件)
voice/ # 语音输入
```
---
## 三、核心架构
### 3.1 启动流程
```
cli.tsx (快速路径分发)
├─ --version → 直接输出,零模块加载
├─ --dump-system-prompt → 转储系统提示feature-gated
├─ --computer-use-mcp → Chrome/Computer Use 模式
├─ --daemon-worker → 后台守护进程
├─ remote-control → 远程控制桥接
└─ 默认交互模式 → main.tsx
├─ 并行初始化MDM、Keychain 预取)
├─ Commander.js CLI 解析
├─ 插件/技能加载
├─ MCP 服务器初始化
└─ REPL.tsx (主 UI)
```
**入口文件详解:**
- **../src/entrypoints/cli.tsx** — 主入口,实现零导入快速路径优化
- 所有导入都是动态的,以最小化模块评估
- 特殊模式处理Chrome 集成、daemon、bridge、后台会话、模板任务等
- 环境特定优化CCR 容器分配 8GB 堆内存)
- **../src/main.tsx** — 完整初始化流程
- 带性能分析检查点的重型初始化
- 并行启动优化MDM 原始读取、Keychain 预取)
- Commander.js CLI 设置
- 会话管理和恢复
- 插件/技能初始化
- MCP 服务器管理
- 分析和遥测设置
### 3.2 查询引擎 (`../src/QueryEngine.ts`)
`QueryEngine` 是整个 LLM 交互的核心:
- **异步生成器模式**`submitMessage()` 以 async generator 流式输出 SDK 消息
- **系统提示组装** — 整合自定义提示、记忆机制、思考配置
- **权限管理**`canUseTool()` 包装,追踪拒绝情况
- **对话循环** — 多轮对话,带 budget/turn 限制
- **会话持久化** — API 响应前保存会话,防崩溃丢数据
- **Snip-boundary 回放** — 长会话的内存管理
- **结构化输出执行** — 带重试限制
- **错误追踪** — 基于 watermark 的 turn 范围界定
**查询管道:**
```
User Input → processUserInput() → QueryEngine.submitMessage()
System Prompt Assembly
Permission Checking
LLM API Call (query.ts)
Stream Response → SDK Messages
```
### 3.3 工具注册 (`../src/tools.ts`)
`getAllBaseTools()` 注册 80+ 工具,通过以下方式过滤:
- Feature flag 条件编译
- 权限上下文过滤
- 模式过滤SIMPLE/REPL/普通)
- 工具预设配置
关键函数:
- `getAllBaseTools()` — 所有工具的真实来源
- `getTools()` — 按权限上下文和模式过滤
- `assembleToolPool()` — 组合内置 + MCP 工具,去重
- 稳定排序以确保 prompt-cache 效率
### 3.4 命令注册 (`../src/commands.ts`)
`getCommands()` 加载 ~150 个命令,来源包括:
- 内置命令
- Bundled skills
- 插件 skills
- MCP 命令
- Workflow 命令
关键特性:
- 通过 lodash memoization 实现延迟求值
- 多源命令组合
- 技能缓存与粒度失效
- `REMOTE_SAFE_COMMANDS` / `BRIDGE_SAFE_COMMANDS` 安全过滤
- `meetsAvailabilityRequirement()` 基于认证/提供商的过滤
### 3.5 REPL 屏幕 (`../src/screens/REPL.tsx`)
- 基于 React 的 Ink TUI支持虚拟滚动
- 基于消息的状态管理,不可变更新
- 复杂的权限处理auto-mode、sandbox、swarm
- 实时流式 LLM 响应
- 多模式输入处理normal、vim、search
- 成本追踪和预算执行
- 后台任务管理
- Swarm/Teammate 多 Agent 协调
---
## 四、五大 API 提供商支持
| 提供商 | 环境变量 | 认证方式 |
|---|---|---|
| **Anthropic默认** | — | `ANTHROPIC_API_KEY` 或 OAuth |
| **OpenAI Codex** | `CLAUDE_CODE_USE_OPENAI=1` | OpenAI OAuth |
| **AWS Bedrock** | `CLAUDE_CODE_USE_BEDROCK=1` | AWS credentials |
| **Google Vertex AI** | `CLAUDE_CODE_USE_VERTEX=1` | gcloud ADC |
| **Anthropic Foundry** | `CLAUDE_CODE_USE_FOUNDRY=1` | API Key |
**API 客户端架构:**
`../src/services/api/client.ts` 是统一的 API 客户端工厂,根据配置自动路由到不同提供商。
**Codex 适配器:**
`../src/services/api/codex-fetch-adapter.ts` 负责将 Anthropic 消息格式转换为 OpenAI Codex 格式:
- Anthropic `base64` 图像 schema 映射到 Codex `input_image` 载荷
- `tool_result` 项路由为顶层 `function_call_output` 对象
- 剥离 Anthropic 专有的 `cache_control` 注解
- 拦截 Codex `response.reasoning.delta` SSE 帧并包装为 Anthropic `<thinking>` 事件
- 绑定 `response.completed` 事件以追踪 token 使用量
### 支持的模型
**Anthropic (Direct API) — 默认:**
| Model | ID |
|---|---|
| Claude Opus 4.6 | `claude-opus-4-6` |
| Claude Sonnet 4.6 | `claude-sonnet-4-6` |
| Claude Haiku 4.5 | `claude-haiku-4-5` |
**OpenAI Codex**
| Model | ID |
|---|---|
| GPT-5.3 Codex (recommended) | `gpt-5.3-codex` |
| GPT-5.4 | `gpt-5.4` |
| GPT-5.4 Mini | `gpt-5.4-mini` |
### 环境变量参考
| Variable | Purpose |
|---|---|
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `ANTHROPIC_AUTH_TOKEN` | Auth token (alternative) |
| `ANTHROPIC_MODEL` | Override default model |
| `ANTHROPIC_BASE_URL` | Custom API endpoint |
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | Custom Opus model ID |
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Custom Sonnet model ID |
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Custom Haiku model ID |
| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token via env |
| `CLAUDE_CODE_API_KEY_HELPER_TTL_MS` | API key helper cache TTL |
| `AWS_REGION` / `AWS_DEFAULT_REGION` | AWS region for Bedrock |
| `ANTHROPIC_BEDROCK_BASE_URL` | Custom Bedrock endpoint |
| `ANTHROPIC_FOUNDRY_API_KEY` | Foundry API key |
---
## 五、Feature Flag 系统
### 构建系统 (`scripts/build.ts`)
- 使用 `bun:bundle``feature()` 宏实现编译时死代码消除
- Bun 编译带字节码生成
- 宏系统提供构建时常量VERSION、BUILD_TIME 等)
### 构建变体
| 命令 | 输出 | Feature Flags | 描述 |
|---|---|---|---|
| `bun run build` | `./cli` | 仅 VOICE_MODE | 生产构建 |
| `bun run build:dev` | `./cli-dev` | 仅 VOICE_MODE | 开发版 |
| `bun run build:dev:full` | `./cli-dev` | 全部 54 个实验性 flag | 完整解锁构建 |
| `bun run compile` | `./dist/cli` | 仅 VOICE_MODE | 替代输出路径 |
| 自定义 | 自定义 | 指定 flags | `bun run ./scripts/build.ts --feature=ULTRAPLAN --feature=ULTRATHINK` |
### 54 个可工作 Feature Flags
#### 交互与 UI14 个)
| Flag | 描述 |
|---|---|
| `AWAY_SUMMARY` | 离开键盘摘要行为 |
| `HISTORY_PICKER` | 交互式提示历史选择器 |
| `HOOK_PROMPTS` | 将 prompt/request 文本传入 hook 执行流程 |
| `KAIROS_BRIEF` | 简短转录布局和 BriefTool UX |
| `KAIROS_CHANNELS` | 频道通知和回调 |
| `LODESTONE` | 深度链接/协议注册相关流程 |
| `MESSAGE_ACTIONS` | 消息操作入口点 |
| `NEW_INIT` | 新版 `/init` 决策路径 |
| `QUICK_SEARCH` | 提示快速搜索 |
| `SHOT_STATS` | 额外的 shot-distribution 统计视图 |
| `TOKEN_BUDGET` | Token 预算追踪和警告 UI |
| `ULTRAPLAN` | 远程多 Agent 规划Opus 级别) |
| `ULTRATHINK` | 深度思考模式 |
| `VOICE_MODE` | 语音切换、听写、语音通知和 UI |
#### Agent/记忆/规划10 个)
| Flag | 描述 |
|---|---|
| `AGENT_MEMORY_SNAPSHOT` | 存储 Agent 记忆快照状态 |
| `AGENT_TRIGGERS` | 本地 cron/触发器工具 |
| `AGENT_TRIGGERS_REMOTE` | 远程触发器工具路径 |
| `BUILTIN_EXPLORE_PLAN_AGENTS` | 内置 explore/plan agent 预设 |
| `CACHED_MICROCOMPACT` | 通过查询和 API 流程缓存的微压缩状态 |
| `COMPACTION_REMINDERS` | 压缩和附件流程的提醒 |
| `EXTRACT_MEMORIES` | 查询后记忆提取 hooks |
| `PROMPT_CACHE_BREAK_DETECTION` | 压缩/查询流程中的缓存中断检测 |
| `TEAMMEM` | 团队记忆文件和 watcher hooks |
| `VERIFICATION_AGENT` | 验证 Agent 指导 |
#### 工具/权限/远程13 个)
| Flag | 描述 |
|---|---|
| `BASH_CLASSIFIER` | 分类器辅助的 bash 权限决策 |
| `BRIDGE_MODE` | 远程控制 / REPL 桥接命令 |
| `CCR_AUTO_CONNECT` | CCR 自动连接默认路径 |
| `CCR_MIRROR` | 仅出站 CCR 镜像会话 |
| `CCR_REMOTE_SETUP` | 远程设置命令路径 |
| `CHICAGO_MCP` | Computer-use MCP 集成 |
| `CONNECTOR_TEXT` | 连接器文本块处理 |
| `MCP_RICH_OUTPUT` | 更丰富的 MCP UI 渲染 |
| `NATIVE_CLIPBOARD_IMAGE` | 原生 macOS 剪贴板图像快速路径 |
| `POWERSHELL_AUTO_MODE` | PowerShell 自动模式权限处理 |
| `TREE_SITTER_BASH` | tree-sitter bash 解析器后端 |
| `TREE_SITTER_BASH_SHADOW` | tree-sitter bash 影子发布路径 |
| `UNATTENDED_RETRY` | API 重试流程中的无人值守重试 |
#### 支撑性 Flag17 个)
| Flag | 描述 |
|---|---|
| `ABLATION_BASELINE` | CLI 消融/基线入口开关 |
| `ALLOW_TEST_VERSIONS` | 允许原生安装程序中的测试版本 |
| `ANTI_DISTILLATION_CC` | 添加反蒸馏请求元数据 |
| `BREAK_CACHE_COMMAND` | 注入 break-cache 命令路径 |
| `COWORKER_TYPE_TELEMETRY` | 添加协作者类型遥测字段 |
| `DOWNLOAD_USER_SETTINGS` | 启用设置同步拉取路径 |
| `DUMP_SYSTEM_PROMPT` | 启用系统提示转储路径 |
| `FILE_PERSISTENCE` | 启用文件持久化管道 |
| `HARD_FAIL` | 启用更严格的失败/日志行为 |
| `IS_LIBC_GLIBC` | 强制 glibc 环境检测 |
| `IS_LIBC_MUSL` | 强制 musl 环境检测 |
| `NATIVE_CLIENT_ATTESTATION` | 添加原生证明标记文本 |
| `PERFETTO_TRACING` | 启用 perfetto 追踪 hooks |
| `SKILL_IMPROVEMENT` | 启用技能改进 hooks |
| `SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED` | 自动更新禁用时跳过更新检测 |
| `SLOW_OPERATION_LOGGING` | 启用慢操作日志 |
| `UPLOAD_USER_SETTINGS` | 启用设置同步推送路径 |
### 运行时注意事项
部分 flag 虽然编译通过但有运行时限制:
- `VOICE_MODE` — 需要 claude.ai OAuth 和本地录制后端
- `NATIVE_CLIPBOARD_IMAGE` — 仅在 macOS 且存在 `image-processor-napi` 时加速
- `BRIDGE_MODE``CCR_*` — 运行时需要 claude.ai OAuth + GrowthBook 权限检查
- `KAIROS_BRIEF``KAIROS_CHANNELS` — 仅暴露已存在的 brief/channel 特定接口
- `CHICAGO_MCP` — 运行时仍需 `@ant/computer-use-*` 外部包
### 34 个不可编译 Flag
#### 易修复15 个)— 缺少单个文件或资产
| Flag | 缺失内容 |
|---|---|
| `AUTO_THEME` | `../src/utils/systemThemeWatcher.js`OSC 11 watcher |
| `BG_SESSIONS` | `../src/cli/bg.js` |
| `BUDDY` | `../src/commands/buddy/index.js` |
| `BUILDING_CLAUDE_APPS` | `../src/claude-api/csharp/claude-api.md`(文档资产) |
| `COMMIT_ATTRIBUTION` | `../src/utils/attributionHooks.js` |
| `FORK_SUBAGENT` | `../src/commands/fork/index.js` |
| `HISTORY_SNIP` | `../src/commands/force-snip.js` |
| `KAIROS_GITHUB_WEBHOOKS` | `../src/tools/SubscribePRTool/SubscribePRTool.js` |
| `KAIROS_PUSH_NOTIFICATION` | `../src/tools/PushNotificationTool/PushNotificationTool.js` |
| `MCP_SKILLS` | `../src/skills/mcpSkills.js` |
| `MEMORY_SHAPE_TELEMETRY` | `../src/memdir/memoryShapeTelemetry.js` |
| `OVERFLOW_TEST_TOOL` | `../src/tools/OverflowTestTool/OverflowTestTool.js` |
| `RUN_SKILL_GENERATOR` | `../src/runSkillGenerator.js` |
| `TEMPLATES` | `../src/cli/handlers/templateJobs.js` |
| `TORCH` | `../src/commands/torch.js` |
| `TRANSCRIPT_CLASSIFIER` | `../src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt` |
#### 中等修复16 个)— 缺少较大子系统部分
| Flag | 缺失内容 |
|---|---|
| `BYOC_ENVIRONMENT_RUNNER` | `../src/environment-runner/main.js` |
| `CONTEXT_COLLAPSE` | `../src/tools/CtxInspectTool/CtxInspectTool.js` |
| `COORDINATOR_MODE` | `../src/coordinator/workerAgent.js` |
| `DAEMON` | `../src/daemon/workerRegistry.js` |
| `DIRECT_CONNECT` | `../src/server/parseConnectUrl.js` |
| `EXPERIMENTAL_SKILL_SEARCH` | `../src/services/skillSearch/localSearch.js` |
| `MONITOR_TOOL` | `../src/tools/MonitorTool/MonitorTool.js` |
| `REACTIVE_COMPACT` | `../src/services/compact/reactiveCompact.js` |
| `REVIEW_ARTIFACT` | `../src/hunter.js` |
| `SELF_HOSTED_RUNNER` | `../src/self-hosted-runner/main.js` |
| `SSH_REMOTE` | `../src/ssh/createSSHSession.js` |
| `TERMINAL_PANEL` | `../src/tools/TerminalCaptureTool/TerminalCaptureTool.js` |
| `UDS_INBOX` | `../src/utils/udsMessaging.js` |
| `WEB_BROWSER_TOOL` | `../src/tools/WebBrowserTool/WebBrowserTool.js` |
| `WORKFLOW_SCRIPTS` | `../src/commands/workflows/index.js` 及多个相关文件 |
#### 大型缺失3 个)— 需要重建完整子系统
| Flag | 缺失内容 |
|---|---|
| `KAIROS` | `../src/assistant/index.js` 及大部分助手栈 |
| `KAIROS_DREAM` | `../src/dream.js` 及 dream-task 行为 |
| `PROACTIVE` | `../src/proactive/index.js` 及主动任务/工具栈 |
---
## 六、核心子系统详解
### 6.1 状态管理 (`../src/state/`)
自定义 Store 模式(非 Redux/Zustand
**核心文件:**
- **store.ts**`getState()/setState()/subscribe()` 基础实现
- 可选 `onChange` 回调用于状态变更追踪
- Listener set 用于 React 风格订阅
- **AppStateStore.ts** — 450+ 行的类型定义,涵盖:
- 设置和配置
- 任务状态管理tasks、agentNameRegistry
- MCP 状态clients、tools、commands、resources
- 插件状态enabled、disabled、errors、installation status
- 远程桥接状态connection、session、polling
- UI 状态expandedView、footerSelection、spinnerTip
- 权限上下文和模式
- 文件历史和归属追踪
- Tungsten (tmux) 集成状态
- Computer Use (chicago MCP) 状态
- Team/swarm 上下文和收件箱管理
- **AppState.tsx** — React 集成:
- `AppStateProvider` 上下文组件
- `useAppState(selector)` — 使用 `useSyncExternalStore` 的优化 hook
- `useSetAppState()` — 非订阅 setter
- `useAppStateStore()` — 直接 store 访问
- `useAppStateMaybeOutsideOfProvider()` — 可选上下文的安全版本
- **selectors.ts** — 纯选择器函数:
- `getViewedTeammateTask()` — 获取当前查看的 teammate
- `getActiveAgentForInput()` — 确定输入路由leader/viewed/named
- **onChangeAppState.ts** — 状态变更副作用处理器:
- 权限模式同步到 CCR/SDK
- 模型设置持久化
- 展开视图持久化
- 设置变更时清除认证缓存
### 6.2 服务层 (`../src/services/`)
28 个主要服务区域:
#### API 客户端 (`../src/services/api/`)
- **client.ts** — 统一 API 客户端工厂,支持五大提供商
- **codex-fetch-adapter.ts** — Anthropic → OpenAI Codex 格式转换
- OAuth token 刷新、自定义 headers、CCH 签名、代理支持
- 错误处理、重试逻辑、使用量追踪
#### OAuth (`../src/services/oauth/`)
- **index.ts** — OAuthService 类,实现 OAuth 2.0 Authorization Code Flow + PKCE
- **client.ts** — OAuth 客户端操作(构建 auth URL、交换 code 为 token、获取 profile
- **auth-code-listener.ts** — 本地 HTTP 服务器用于自动 OAuth 回调处理
- **crypto.ts** — PKCE code verifier/challenge 生成
- **codex-client.ts** — Codex 特定 OAuth 客户端
#### MCP 集成 (`../src/services/mcp/`)
- **client.ts** — MCP 客户端管理
- **types.ts** — MCP 服务器配置的全面类型定义stdio、SSE、HTTP、WebSocket、SDK、IDE 特定)
- **config.ts** — MCP 配置管理
- **useManageMCPConnections.ts** — MCP 连接生命周期的 React hook
- **channelPermissions.ts** — MCP 频道权限处理Telegram、iMessage 等)
- 支持 XAACross-App Access、elicitation 处理、资源管理
#### 分析 (`../src/services/analytics/`) — 已 STUB
- 所有函数都是空操作logEvent、attachAnalyticsSink 等)
- 作为兼容性边界,使现有调用点保持不变而遥测被禁用
- 包括datadog.ts、firstPartyEventLogger.ts、growthbook.ts、sink.ts
#### 其他主要服务
| 服务 | 职责 | 状态 |
|---|---|---|
| `compact/` | 上下文压缩auto-compact、micro-compact、reactive compact | 完整 |
| `tools/` | 工具执行编排StreamingToolExecutor、toolOrchestration | 完整 |
| `plugins/` | 插件操作和安装管理 | 完整 |
| `SessionMemory/` | 会话记忆持久化和检索 | 完整 |
| `skillSearch/` | 技能搜索,带远程状态管理 | 完整 |
| `remoteManagedSettings/` | 远程托管设置同步 | 完整 |
| `extractMemories/` | 从对话上下文中提取记忆 | 完整 |
| `lsp/` | Language Server Protocol 集成 | 完整 |
| `contextCollapse/` | 上下文窗口优化 | 完整 |
| `tips/` | 用户提示和指导系统 | 完整 |
| `voice.ts` | 语音集成STT、keyterms、streaming | 完整 |
### 6.3 IDE 桥接 (`../src/bridge/`)
远程控制架构:
1. 注册环境到后端(机器名、目录、分支、仓库 URL
2. 轮询获取 work items要执行的会话
3. 为每个会话创建子进程
4. 管理会话生命周期(心跳、停止、归档)
5. 权限响应回传
6. 支持多种生成模式单会话、worktree、同目录
**核心文件:**
- **bridgeApi.ts** — Bridge API 客户端(环境注册/注销、工作轮询/确认、会话生命周期管理)
- **types.ts** — 全面的桥接类型定义260+ 行)
- **bridgeConfig.ts** — 桥接认证/URL 解析
- **remoteBridgeCore.ts** — 远程桥接核心逻辑
- **replBridgeHandle.ts** — REPL 桥接句柄实现
- **replBridgeTransport.ts** — 桥接传输层
- **createSession.ts** — 会话创建逻辑
- **sessionRunner.ts** — 会话执行运行器
- **jwtUtils.ts** — JWT token 工具
- **peerSessions.ts** — 对等会话管理
- **trustedDevice.ts** — 受信任设备认证
### 6.4 任务系统 (`../src/tasks/`)
7 种任务类型:
| 任务类型 | 用途 |
|---|---|
| `LocalShellTask` | 后台 Bash 命令执行 |
| `LocalAgentTask` | 本地 Agent 子进程(自主工作) |
| `RemoteAgentTask` | 远程 CCR Agent通过 CCR API 执行) |
| `InProcessTeammateTask` | 进程内 Teammate共享主进程 |
| `LocalWorkflowTask` | 预定义工作流脚本 |
| `MonitorMcpTask` | MCP 资源变更监控 |
| `DreamTask` | 异步/Dream 任务 |
**任务管理特性:**
- 任务状态pending → in_progress → completed或 deleted
- 后台任务:带 `isBackgrounded` 标志
- 任务依赖:`blocks``blockedBy` 关系
- 任务所有权:可分配给特定 Agent
- 团队任务列表:团队共享任务列表以协调工作
### 6.5 工具系统 (`../src/tools/`)
40+ 工具按功能分类:
#### 文件操作
| 工具 | 功能 |
|---|---|
| FileReadTool | 读取文件支持图像、PDF、Jupyter notebook |
| FileWriteTool | 写入文件(需先读取现有文件) |
| FileEditTool | 精确字符串替换(支持 replace_all |
| GlobTool | 快速文件模式匹配 |
| GrepTool | 基于 ripgrep 的内容搜索 |
| NotebookEditTool | Jupyter notebook 单元格编辑 |
| SnipTool | 代码片段管理 |
| SendUserFileTool | 向用户发送文件 |
#### Shell 和系统
| 工具 | 功能 |
|---|---|
| BashTool | 执行 bash 命令(支持后台执行、超时、沙盒) |
| PowerShellTool | 执行 PowerShell 命令Windows |
| SleepTool | 等待指定时间 |
| TerminalCaptureTool | 终端捕获 |
#### 通信和规划
| 工具 | 功能 |
|---|---|
| AskUserQuestionTool | 向用户提问(支持多选、预览) |
| SendMessageTool | 向其他 Agent 发送消息 |
| EnterPlanModeTool | 进入规划模式 |
| ExitPlanModeTool | 退出规划模式 |
| BriefTool | 向用户发送消息(主通信通道) |
#### Web 和网络
| 工具 | 功能 |
|---|---|
| WebFetchTool | 获取 URL 内容HTML → markdown15 分钟缓存) |
| WebSearchTool | Web 搜索 |
| WebBrowserTool | Web 浏览器面板 |
| RemoteTriggerTool | 管理远程 Agent 触发器 |
#### MCP
| 工具 | 功能 |
|---|---|
| MCPTool | 通用 MCP 工具包装器 |
| ListMcpResourcesTool | 列出 MCP 服务器资源 |
| ReadMcpResourceTool | 读取 MCP 服务器资源 |
| McpAuthTool | MCP 认证处理 |
#### Agent 和团队
| 工具 | 功能 |
|---|---|
| AgentTool | 启动专门的 Agent 子进程 |
| TeamCreateTool | 创建 Agent 团队 |
| TeamDeleteTool | 删除 Agent 团队 |
#### 任务管理
| 工具 | 功能 |
|---|---|
| TaskCreateTool | 创建任务列表 |
| TaskUpdateTool | 更新任务 |
| TaskGetTool | 获取任务详情 |
| TaskListTool | 列出所有任务 |
| TaskStopTool | 停止后台任务 |
| TaskOutputTool | 查看后台任务输出 |
#### Git
| 工具 | 功能 |
|---|---|
| EnterWorktreeTool | 创建隔离的 git worktree |
| ExitWorktreeTool | 退出 worktree |
#### 调度
| 工具 | 功能 |
|---|---|
| CronCreateTool | 创建定时任务 |
| CronDeleteTool | 删除定时任务 |
| CronListTool | 列出定时任务 |
#### 配置和发现
| 工具 | 功能 |
|---|---|
| ConfigTool | 获取/设置配置 |
| ToolSearchTool | 获取延迟加载工具的 schema |
| DiscoverSkillsTool | 发现可用技能 |
| LSPTool | LSP 服务器交互goToDefinition、findReferences 等) |
#### 验证
| 工具 | 功能 |
|---|---|
| VerifyPlanExecutionTool | 验证计划执行 |
**工具延迟加载:**
- 工具可被延迟加载(不包含在初始 prompt 中)以减少上下文
- 延迟加载的工具需要 ToolSearch 来获取 schema
- MCP 工具始终延迟加载
- 部分工具永不延迟Agent、Brief when KAIROS enabled
### 6.6 命令系统 (`../src/commands/`)
70+ 斜杠命令:
#### 配置和设置
| 命令 | 功能 |
|---|---|
| `/config` (别名: `/settings`) | 打开配置面板 |
| `/keybindings` | 打开/创建快捷键配置文件 |
| `/add-dir` | 添加新的工作目录 |
| `/memory` | 编辑记忆文件 |
| `/sandbox` | 切换沙盒模式 |
| `/vim` | 切换 Vim/普通编辑模式 |
| `/terminal-setup` | 安装 Shift+Enter 换行绑定 |
| `/color` | 设置提示栏颜色 |
| `/effort` | 设置模型努力级别 |
| `/model` | 更改模型设置 |
| `/fast` | 切换快速模式 |
| `/theme` | 更改主题 |
#### 任务和会话管理
| 命令 | 功能 |
|---|---|
| `/tasks` (别名: `/bashes`) | 列出和管理后台任务 |
| `/plan` | 启用规划模式或查看当前计划 |
| `/branch` (别名: `/fork`) | 在当前点创建对话分支 |
| `/resume` (别名: `/continue`) | 恢复之前的对话 |
| `/session` | 会话管理 |
| `/compact` | 清除对话历史但保留摘要 |
| `/exit` | 退出 CLI |
| `/btw` | 快速旁问(不打断主对话) |
#### 信息和诊断
| 命令 | 功能 |
|---|---|
| `/help` | 显示帮助信息 |
| `/context` | 可视化当前上下文使用情况 |
| `/cost` | 显示当前会话的总成本和时长 |
| `/usage` | 显示使用信息 |
| `/stats` | 显示统计信息 |
| `/doctor` | 诊断和验证安装 |
| `/status` | 显示状态信息 |
| `/statusline` | 状态栏管理 |
| `/release-notes` | 显示发行说明 |
#### 功能和集成
| 命令 | 功能 |
|---|---|
| `/ide` | 管理 IDE 集成 |
| `/agents` | 管理 Agent 配置 |
| `/skills` | 技能管理 |
| `/plugin` | 插件管理 |
| `/mcp` | MCP 管理 |
| `/voice` | 语音模式 |
| `/remote-control` (别名: `/rc`) | 远程控制会话 |
| `/chrome` | Chrome 集成 |
#### Git 和版本控制
| 命令 | 功能 |
|---|---|
| `/commit` | 创建 git 提交(技能) |
| `/commit-push-pr` | 提交、推送并创建 PR |
| `/diff` | 显示差异 |
| `/tag` | 标签管理 |
#### 账户和认证
| 命令 | 功能 |
|---|---|
| `/login` | 登录账户 |
| `/logout` | 登出账户 |
| `/upgrade` | 升级到 Max |
| `/permissions` | 管理权限 |
| `/hooks` | Hooks 管理 |
| `/init` | 初始化 Claude Code |
### 6.7 技能系统 (`../src/skills/`)
13+ 个内置技能:
| 技能 | 功能 |
|---|---|
| `/update-config` | 通过 settings.json 配置 hooks 和自动化行为 |
| `/simplify` | 审查代码质量(启动 3 个并行 Agent |
| `/loop` | 定时循环执行命令 |
| `/verify` | 验证代码变更 |
| `/remember` | 审查自动记忆并提议提升到 CLAUDE.md |
| `/debug` | 调试技能 |
| `/keybindings` | 快捷键管理 |
| `/batch` | 批量操作 |
| `/stuck` | 获取解困帮助 |
| `/skillify` | 创建新技能 |
| `/claudeApi` | Claude API 文档和示例 |
| `/dream` | Dream 技能 |
| `/hunter` | 代码审查工件 |
| `/runSkillGenerator` | 运行技能生成器 |
**技能系统架构:**
- **注册**`registerBundledSkill()` 在启动时注册技能
- **定义** — 技能包含 name、description、aliases、whenToUse、allowedTools、model、hooks
- **引用文件** — 首次调用时提取到磁盘
- **发现** — 技能出现在 Skill 工具提示中bundled 技能永不截断)
- **调用** — 通过 Skill 工具或 `/skill-name` 斜杠命令
### 6.8 插件系统 (`../src/plugins/`)
- 内置插件格式:`{name}@builtin`
- 区别于市场插件:`{name}@{marketplace}`
- 可提供 skills、hooks、MCP servers
- 通过 `/plugin` UI 管理
- 用户设置控制启用状态(默认:`defaultEnabled ?? true`
---
## 七、React Hooks (`../src/hooks/`)
104 个 React hooks 按类别:
### 数据获取和状态
- `useTasksV2``useTaskListWatcher` — 任务管理
- `useSettings``useSettingsChange` — 设置同步
- `useApiKeyVerification` — API key 验证
- `useInboxPoller` — 消息收件箱轮询
- `useDiffData` — Diff 数据计算
### UI/交互
- `useTypeahead` — 输入自动完成
- `useSearchInput` — 搜索功能
- `useArrowKeyHistory` — 命令历史导航
- `useVirtualScroll` — 虚拟滚动
- `usePasteHandler` — 剪贴板粘贴处理
- `useBlink` — 光标闪烁效果
### IDE 集成
- `useIdeLogging` — IDE 日志
- `useDiffInIDE` — IDE diff 查看
- `useIdeAtMentioned` — IDE @mention 处理
- `useLspPluginRecommendation` — LSP 插件建议
### 功能特定
- `useVoice``useVoiceIntegration``useVoiceEnabled` — 语音功能
- `useDirectConnect` — 直接连接模式
- `useSSHSession` — SSH 会话管理
- `useBackgroundTaskNavigation` — 后台任务路由
- `useSwarmInitialization``useSwarmPermissionPoller` — Agent swarm 管理
### 工具
- `useTerminalSize` — 终端尺寸
- `useMemoryUsage` — 内存监控
- `useScheduledTasks` — 任务调度
- `useDynamicConfig` — 动态配置加载
---
## 八、UI 组件 (`../src/components/`)
27 个主要组件目录:
### 核心 UI
- **ui/** — 基础 UI 组件(按钮、输入框等)
- **design-system/** — 设计系统组件
### 功能组件
- **tasks/** — 任务列表和管理 UI
- **messages/** — 消息显示和渲染
- **permissions/** — 权限请求对话框
- **mcp/** — MCP 服务器管理 UI
- **diff/** — 代码差异查看
- **shell/** — Shell/输出显示
- **memory/** — 记忆管理 UI
- **sandbox/** — 沙盒权限
- **agents/** — Agent 创建和管理
### 设置和配置
- **Settings/** — 设置面板
- **ManagedSettingsSecurityDialog/** — 托管设置安全对话框
### 集成
- **groove/** — Grove 集成
- **teams/** — 团队协作
- **skills/** — 技能管理
- **wizard/** — 引导向导
- **LspRecommendation/** — LSP 插件建议
### 专用
- **TrustDialog/** — 信任/安全对话框
- **FeedbackSurvey/** — 用户反馈
- **Spinner/** — 加载旋转器
- **PromptInput/** — 输入提示组件
- **HelpV2/** — 帮助系统
- **LogoV2/** — Logo 组件
---
## 九、构建与依赖
### 运行时依赖(关键)
| 类别 | 包 |
|---|---|
| **AI SDK** | @anthropic-ai/sdk, @anthropic-ai/bedrock-sdk, @anthropic-ai/vertex-sdk, @anthropic-ai/foundry-sdk |
| **Agent SDK** | @anthropic-ai/claude-agent-sdk |
| **MCP** | @modelcontextprotocol/sdk, @anthropic-ai/mcpb |
| **终端 UI** | react, ink, chalk, cli-highlight |
| **搜索** | fuse.js, picomatch |
| **协议** | vscode-jsonrpc, vscode-languageserver-protocol |
| **可观测性** | @opentelemetry/* (完整 suite但遥测已 stub) |
| **验证** | zod, ajv |
| **图片** | sharp |
| **网络** | axios, undici, ws, https-proxy-agent |
| **CLI** | @commander-js/extra-typings |
| **其他** | diff, marked, yaml, lodash-es, lru-cache, semver, chokidar |
### 开发依赖
- `@types/bun` — Bun 类型定义
- `typescript ^6.0.2` — TypeScript 编译器
---
## 十、Git 状态
当前仓库只有 **1 个 commit**
```
a4deee0 因无法 fork手动迁移代码 source: https://github.com/paoloanzn/free-code
```
**76 个未跟踪文件/目录**包括许多新增的子系统文件assistant、buddy、fork、workflows、daemon、ssh 等),对应于之前不可编译但已被手动补充代码的 feature flags。
**3 个已修改文件**`bun.lock``package.json``scripts/build.ts`
---
## 十一、架构亮点与设计模式
1. **编译时 Feature Flag**`bun:bundle``feature()` 宏实现真正的死代码消除,非运行时 if/else
2. **异步生成器查询管线** — QueryEngine 用 async generator 实现流式 LLM 响应
3. **多 Agent 协作** — Agent Swarm 支持 Team 创建、共享任务列表、跨会话消息传递
4. **工具延迟加载** — 大型工具集通过 ToolSearch 按需加载 schema优化 context window
5. **多提供商 API 抽象** — 统一接口适配 5 个 API 提供商
6. **自定义状态管理** — 轻量 store + useSyncExternalStore非 Redux
7. **IDE 桥接轮询架构** — 远程控制通过环境注册 + work polling 实现
8. **上下文压缩** — 多级压缩策略auto-compact、micro-compact、reactive-compact
9. **MCP 协议完整实现** — 支持 stdio、SSE、HTTP、WebSocket、SDK 等多种传输方式
10. **沙盒安全** — 文件/网络访问控制,支持 macOS/Linux 平台
---
## 十二、工具函数层 (`../src/utils/`)
最大的源码目录577 文件178,924 行):
### 模型管理 (`../src/utils/model/`)
- **model.ts** — 核心模型选择逻辑、别名、规范化
- **modelStrings.ts** — 模型名称常量和映射
- **providers.ts** — API 提供商检测
- **modelCapabilities.ts** — 模型能力标志
- **modelAllowlist.ts** — 模型访问控制
- **validateModel.ts** — 模型验证
- **modelCost.ts** — 模型定价信息
- **check1mAccess.ts** — 1M 上下文访问检查
### 设置管理 (`../src/utils/settings/`)
- **settings.ts** — 设置加载和持久化
- **validation.ts** — 设置验证
- **types.ts** — 设置类型定义
- **applySettingsChange.ts** — 设置变更应用
- **permissionValidation.ts** — 权限验证
- **mdm/** — 移动设备管理集成
### 其他重要工具模块
- **auth.ts** — 认证和凭证管理67KB1800+ 行)
- **attachments.ts** — 附件处理127KB
- **analyzeContext.ts** — 上下文分析43KB
- **ansiToPng.ts** — ANSI 转 PNG215KB
- **bash/** — Bash 命令执行
- **permissions/** — 权限系统
- **config.ts** — 配置管理
- **todo/** — TODO 列表管理
- **commitAttribution.ts** — Git 提交归属
---
*报告生成日期2026-04-05*

View File

@ -0,0 +1,43 @@
# 基础设施设计 — 原始代码映射
## 文档元数据
- 项目名称: free-code
- 文档类型: 原始代码映射
- 原始代码来源: `../../../src/` 下相关基础设施子目录
- 交叉引用: [基础设施设计总览](../基础设施设计.md)
## .NET 类型 → 原始 TypeScript 源文件
| .NET 类型 | 原始 TypeScript 源文件 |
|---|---|
| IMcpClientManager | `../../../src/services/mcp/` |
| MCPServerConnection | `../../../src/services/mcp/` |
| ScopedMcpServerConfig | `../../../src/services/mcp/` |
| StdioTransport | `../../../src/services/mcp/` |
| SseTransport | `../../../src/services/mcp/` |
| StreamableHttpTransport | `../../../src/services/mcp/` |
| InProcessTransport | `../../../src/services/mcp/` |
| McpClient | `../../../src/services/mcp/` |
| McpClientManager | `../../../src/services/mcp/` |
| McpAuthService | `../../../src/services/mcp/` |
| ILspClientManager | `../../../src/services/lsp/` |
| LspServerInstance | `../../../src/services/lsp/` |
| LspClientManager | `../../../src/services/lsp/` |
| LspDiagnosticRegistry | `../../../src/services/lsp/` |
| IBridgeService | `../../../src/bridge/` |
| BridgeConfig | `../../../src/bridge/` |
| SpawnMode | `../../../src/bridge/` |
| BridgeService | `../../../src/bridge/` |
| SessionSpawner | `../../../src/bridge/` |
| IBridgeApiClient | `../../../src/bridge/` |
| BackgroundTask | `../../../src/tasks/` |
| IBackgroundTaskManager | `../../../src/tasks/` |
| BackgroundTaskManager | `../../../src/tasks/` |
| AppState | `../../../src/state/` |
| McpState | `../../../src/state/` |
| PluginState | `../../../src/state/` |
| NotificationState | `../../../src/state/` |
| SpeculationState | `../../../src/state/` |
| IAppStateStore | `../../../src/state/` |
| AppStateStore | `../../../src/state/` |
| StateSelectors | `../../../src/state/` |

View File

@ -0,0 +1,203 @@
# 基础设施设计 — IDE 桥接
## 文档元数据
- 项目名称: free-code
- 文档类型: 基础设施设计
- 原始代码来源: `../../src/bridge/`32个文件
- 原始设计意图: 将 claude.ai 远程控制、会话启动、工作轮询和 IDE 侧事件流统一到一个可恢复的桥接服务中
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
## 设计目标
IDE 桥接层负责把编辑器、运行时与后台工作流连接起来,提供会话启动、消息转发、任务轮询和工作区隔离能力。它既要支撑单会话模型,也要允许 worktree 隔离与环境注册/注销。
## 12.1 IBridgeService 接口
```csharp
/// <summary>
/// IDE 桥接服务 — 实现 claude.ai 远程控制功能
/// 对应原始 bridgeMain.ts / replBridge.ts
/// </summary>
public interface IBridgeService
{
/// <summary>注册环境到 claude.ai</summary>
Task<BridgeEnvironment> RegisterEnvironmentAsync();
/// <summary>轮询获取工作项</summary>
Task<WorkItem?> PollForWorkAsync(CancellationToken ct);
/// <summary>启动会话</summary>
Task<SessionHandle> SpawnSessionAsync(SessionSpawnOptions options);
/// <summary>确认工作项</summary>
Task AcknowledgeWorkAsync(string workId, string sessionToken);
/// <summary>发送权限响应</summary>
Task SendPermissionResponseAsync(string sessionId, PermissionResponse response);
/// <summary>心跳(延长工作租约)</summary>
Task HeartbeatAsync(string workId, string sessionToken);
/// <summary>停止工作</summary>
Task StopWorkAsync(string workId);
/// <summary>注销环境</summary>
Task DeregisterEnvironmentAsync();
/// <summary>桥接状态</summary>
BridgeStatus Status { get; }
}
```
## 12.2 BridgeConfig 与 SpawnMode
```csharp
/// <summary>
/// 桥接配置 — 对应原始 BridgeConfig
/// </summary>
public record BridgeConfig
{
public required string Directory { get; init; }
public required string MachineName { get; init; }
public required string Branch { get; init; }
public string? GitRepoUrl { get; init; }
public int MaxSessions { get; init; } = 1;
public SpawnMode SpawnMode { get; init; } = SpawnMode.Worktree;
public bool Verbose { get; init; }
public bool Sandbox { get; init; }
public required Guid BridgeId { get; init; }
public required string WorkerType { get; init; } // "claude_code" | "claude_code_assistant"
public required Guid EnvironmentId { get; init; }
public string? ReuseEnvironmentId { get; init; }
public required string ApiBaseUrl { get; init; }
public required string SessionIngressUrl { get; init; }
public int SessionTimeoutMs { get; init; } = 24 * 60 * 60 * 1000; // 24h
}
/// <summary>会话生成模式</summary>
public enum SpawnMode
{
SingleSession, // 单次会话, 结束即销毁
Worktree, // 持久, 每会话隔离 git worktree
SameDir // 持久, 共享工作目录
}
```
## 12.3 BridgeService 实现
```csharp
public sealed class BridgeService : IBridgeService, IAsyncDisposable
{
private readonly IBridgeApiClient _apiClient;
private readonly ISessionSpawner _sessionSpawner;
private readonly IBridgeLogger _logger;
private readonly BridgeConfig _config;
private readonly ConcurrentDictionary<string, SessionHandle> _activeSessions = new();
private string? _environmentId;
private string? _environmentSecret;
private BridgeStatus _status = BridgeStatus.Idle;
public async Task<BridgeEnvironment> RegisterEnvironmentAsync()
{
var result = await _apiClient.RegisterBridgeEnvironment(_config);
_environmentId = result.EnvironmentId;
_environmentSecret = result.EnvironmentSecret;
_status = BridgeStatus.Registered;
return new BridgeEnvironment(result.EnvironmentId, result.EnvironmentSecret);
}
/// <summary>
/// 主轮询循环 — 对应原始 bridgeMain.ts 的 pollForWork loop
/// </summary>
public async Task RunAsync(CancellationToken ct)
{
await RegisterEnvironmentAsync();
_logger.PrintBanner(_config, _environmentId!);
while (!ct.IsCancellationRequested)
{
try
{
var work = await PollForWorkAsync(ct);
if (work == null) continue;
_status = BridgeStatus.Attached;
_logger.LogSessionStart(work.Id, work.Data.ToString());
// 解码 work secret → 获取 session token
var secret = DecodeWorkSecret(work.Secret);
await AcknowledgeWorkAsync(work.Id, secret.SessionIngressToken);
// 生成会话 (可能使用 worktree 隔离)
var sessionDir = await PrepareSessionDirectoryAsync(work);
var handle = await _sessionSpawner.SpawnAsync(new SessionSpawnOptions
{
SessionId = work.Id,
SdkUrl = secret.ApiBaseUrl,
AccessToken = secret.SessionIngressToken,
WorkingDirectory = sessionDir,
}, ct);
_activeSessions[work.Id] = handle;
// 等待会话完成
_ = MonitorSessionAsync(work.Id, handle, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError($"Poll error: {ex.Message}");
await Task.Delay(CalculateBackoff(), ct);
}
}
}
private async Task MonitorSessionAsync(
string sessionId, SessionHandle handle, CancellationToken ct)
{
var doneStatus = await handle.Done;
_activeSessions.TryRemove(sessionId, out _);
_logger.LogSessionComplete(sessionId, 0);
_status = _activeSessions.IsEmpty ? BridgeStatus.Registered : BridgeStatus.Attached;
}
public async ValueTask DisposeAsync()
{
foreach (var handle in _activeSessions.Values)
handle.Kill();
if (_environmentId != null)
await DeregisterEnvironmentAsync();
}
}
```
## 12.4 IBridgeApiClient
```csharp
/// <summary>
/// 桥接 API 客户端 — 对应原始 bridgeApi.ts
/// </summary>
public interface IBridgeApiClient
{
Task<(string EnvironmentId, string EnvironmentSecret)> RegisterBridgeEnvironment(BridgeConfig config);
Task<WorkResponse?> PollForWork(string envId, string envSecret, CancellationToken ct);
Task AcknowledgeWork(string envId, string workId, string sessionToken);
Task StopWork(string envId, string workId);
Task DeregisterEnvironment(string envId);
Task SendPermissionResponseEvent(string sessionId, PermissionResponseEvent evt, string token);
Task ArchiveSession(string sessionId);
Task HeartbeatWork(string envId, string workId, string sessionToken);
}
```
## 12.5 生命周期与传输策略
- 环境注册先于轮询;只有拿到 `EnvironmentId` / `EnvironmentSecret` 后才开始接收工作项。
- `RunAsync` 负责持续轮询,`SpawnSessionAsync` 负责把单个 work item 落到独立会话中。
- `Worktree` 模式用于最强隔离,`SameDir` 用于低开销复用,`SingleSession` 用于短生命周期任务。
- 传输层上保持 API 侧长轮询/轮询式控制,避免把 IDE 控制流绑死在单一 socket 连接上。
- `HeartbeatAsync` 用于延长工作租约,防止长任务被服务端提前回收。
## 12.6 补充说明
- 该层的重点不是本地执行,而是把远端工作项和本地会话生命周期做稳定映射。
- 所有状态变化最终都应回写到上层状态存储,避免桥接层形成隐式状态孤岛。

View File

@ -0,0 +1,368 @@
# 基础设施设计 — LSP 集成
## 文档元数据
- 项目名称: free-code
- 文档类型: 基础设施设计
- 原始代码来源: `../../src/services/lsp/`7个文件
- 原始设计意图: 在 .NET 中封装语言服务器生命周期、文件同步与 9 种核心 LSP 操作,并支持按扩展名路由与诊断基线比较
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-工具系统](../核心模块设计/核心模块设计-工具系统.md)
## 设计目标
LSP 层用于承载代码补全、诊断、跳转与重命名等 IDE 能力,为编辑体验提供统一协议封装。它既要兼容多语言服务器,又要支持懒启动、文件同步和跨文件诊断聚合。
## 11.1 ILspClientManager 接口定义
```csharp
/// <summary>
/// LSP 客户端管理器 — 管理 LSP 服务器实例
/// 对应原始 LSPServerManager.ts
/// </summary>
public interface ILspClientManager
{
/// <summary>初始化 (加载配置, 懒启动)</summary>
Task InitializeAsync(CancellationToken ct = default);
/// <summary>关闭所有服务器</summary>
Task ShutdownAsync();
/// <summary>获取文件对应的 LSP 服务器</summary>
ILspServerInstance? GetServerForFile(string filePath);
/// <summary>确保文件对应的 LSP 服务器已启动</summary>
Task<ILspServerInstance?> EnsureServerStartedAsync(string filePath);
/// <summary>发送请求到文件对应的 LSP 服务器</summary>
Task<T?> SendRequestAsync<T>(string filePath, string method, object? parameters);
// LSP 操作 (对应原始 LSPTool 的 9 种操作)
Task<Location?> GoToDefinitionAsync(string filePath, int line, int character);
Task<Location[]> FindReferencesAsync(string filePath, int line, int character);
Task<Hover?> HoverAsync(string filePath, int line, int character);
Task<Symbol[]> DocumentSymbolsAsync(string filePath);
Task<Symbol[]> WorkspaceSymbolsAsync(string query);
Task<Diagnostic[]> GetDiagnosticsAsync(string filePath);
Task<PrepareRenameResult?> PrepareRenameAsync(string filePath, int line, int character);
Task<WorkspaceEdit?> RenameAsync(string filePath, int line, int character, string newName);
Task<CodeAction[]> GetCodeActionsAsync(string filePath, int line, int character);
// 文件同步
Task OpenFileAsync(string filePath, string content);
Task ChangeFileAsync(string filePath, string content);
Task SaveFileAsync(string filePath);
Task CloseFileAsync(string filePath);
/// <summary>所有运行中的服务器</summary>
IReadOnlyDictionary<string, ILspServerInstance> GetAllServers();
/// <summary>是否至少有一个服务器已连接</summary>
bool IsConnected { get; }
}
```
## 11.2 LspServerInstance 实现
```csharp
/// <summary>
/// 单个 LSP 服务器实例 — 管理子进程生命周期
/// 对应原始 LSPServerInstance.ts
/// </summary>
public interface ILspServerInstance
{
string Name { get; }
string Command { get; }
IReadOnlyDictionary<string, string> ExtensionToLanguage { get; }
LspServerState State { get; }
Task StartAsync();
Task StopAsync();
Task<T> SendRequestAsync<T>(string method, object? parameters);
Task SendNotificationAsync(string method, object? parameters);
void OnRequest(string method, Func<object, object?> handler);
}
public enum LspServerState { Stopped, Starting, Running, Error }
/// <summary>
/// LSP 服务器实例实现
/// </summary>
public sealed class LspServerInstance : ILspServerInstance, IAsyncDisposable
{
private Process? _process;
private JsonRpc? _rpc; // 使用 StreamJsonRpc 库
private readonly string _name;
private readonly ScopedLspServerConfig _config;
private readonly ILogger _logger;
private readonly Dictionary<string, Func<object, object?>> _requestHandlers = new();
public LspServerState State { get; private set; } = LspServerState.Stopped;
public async Task StartAsync()
{
if (State == LspServerState.Running) return;
State = LspServerState.Starting;
var psi = new ProcessStartInfo
{
FileName = _config.Command,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var arg in _config.Args)
psi.ArgumentList.Add(arg);
if (_config.Env != null)
foreach (var (k, v) in _config.Env)
psi.Environment[k] = v;
_process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start LSP server {_name}");
// 使用 StreamJsonRpc 建立 LSP 连接
_rpc = JsonRpc.Attach(_process.StandardInput.BaseStream, _process.StandardOutput.BaseStream);
_rpc.Disconnected += (_, _) => State = LspServerState.Stopped;
// 发送 initialize 请求
var initResult = await _rpc.InvokeAsync<LspInitializeResult>("initialize", new
{
processId = Environment.ProcessId,
rootUri = PathToFileUri(Directory.GetCurrentDirectory()),
capabilities = new
{
textDocument = new
{
definition = new { dynamicRegistration = false },
references = new { dynamicRegistration = false },
hover = new { dynamicRegistration = false, contentFormat = new[] { "markdown", "plaintext" } },
publishDiagnostics = new { relatedInformation = true },
rename = new { prepareSupport = true }
}
}
});
// 发送 initialized 通知
await _rpc.NotifyAsync("initialized", new { });
State = LspServerState.Running;
}
public Task<T> SendRequestAsync<T>(string method, object? parameters)
=> _rpc!.InvokeAsync<T>(method, parameters);
public Task SendNotificationAsync(string method, object? parameters)
=> _rpc!.NotifyAsync(method, parameters);
public async ValueTask DisposeAsync()
{
if (_rpc != null)
{
try { await _rpc.NotifyAsync("shutdown", null); } catch { }
try { await _rpc.NotifyAsync("exit", null); } catch { }
}
if (_process?.HasExited == false)
{
_process.WaitForExit(5000);
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
}
State = LspServerState.Stopped;
}
}
```
## 11.3 LspClientManager 实现
```csharp
public sealed class LspClientManager : ILspClientManager
{
private readonly ConcurrentDictionary<string, ILspServerInstance> _servers = new();
private readonly Dictionary<string, List<string>> _extensionMap = new(); // ext → [serverNames]
private readonly HashSet<string> _openedFiles = new(); // URI 跟踪
private readonly ILogger<LspClientManager> _logger;
public bool IsConnected => _servers.Values.Any(s => s.State == LspServerState.Running);
/// <summary>
/// 初始化: 加载 LSP 配置,构建扩展映射,不启动服务器(懒启动)
/// 对应原始 initializeLspServerManager()
/// </summary>
public async Task InitializeAsync(CancellationToken ct = default)
{
var configs = await LoadAllLspServerConfigsAsync();
foreach (var (name, config) in configs)
{
if (string.IsNullOrEmpty(config.Command))
{
_logger.LogWarning("LSP server {Name} missing command, skipping", name);
continue;
}
// 构建扩展 → 服务器映射
foreach (var ext in config.ExtensionToLanguage.Keys)
{
var normalized = ext.ToLowerInvariant();
if (!_extensionMap.ContainsKey(normalized))
_extensionMap[normalized] = new();
_extensionMap[normalized].Add(name);
}
_servers[name] = new LspServerInstance(name, config, _logger);
}
}
public ILspServerInstance? GetServerForFile(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
var serverNames = _extensionMap.GetValueOrDefault(ext);
return serverNames?.Count > 0 ? _servers.GetValueOrDefault(serverNames[0]) : null;
}
public async Task<ILspServerInstance?> EnsureServerStartedAsync(string filePath)
{
var server = GetServerForFile(filePath);
if (server == null) return null;
if (server.State is LspServerState.Stopped or LspServerState.Error)
await server.StartAsync();
return server;
}
public async Task<T?> SendRequestAsync<T>(string filePath, string method, object? parameters)
{
var server = await EnsureServerStartedAsync(filePath);
if (server == null) return default;
return await server.SendRequestAsync<T>(method, parameters);
}
// === LSP 操作实现 ===
public async Task<Location?> GoToDefinitionAsync(string filePath, int line, int character)
=> await SendRequestAsync<Location?>(filePath, "textDocument/definition",
new { textDocument = new { uri = PathToFileUri(filePath) },
position = new { line = line - 1, character } });
public async Task<Location[]> FindReferencesAsync(string filePath, int line, int character)
=> await SendRequestAsync<Location[]>(filePath, "textDocument/references",
new { textDocument = new { uri = PathToFileUri(filePath) },
position = new { line = line - 1, character },
context = new { includeDeclaration = true } }) ?? [];
public async Task<Hover?> HoverAsync(string filePath, int line, int character)
=> await SendRequestAsync<Hover?>(filePath, "textDocument/hover",
new { textDocument = new { uri = PathToFileUri(filePath) },
position = new { line = line - 1, character } });
public async Task<Symbol[]> DocumentSymbolsAsync(string filePath)
=> await SendRequestAsync<Symbol[]>(filePath, "textDocument/documentSymbol",
new { textDocument = new { uri = PathToFileUri(filePath) } }) ?? [];
public async Task<Symbol[]> WorkspaceSymbolsAsync(string query)
{
// 遍历所有已启动的服务器
var results = new List<Symbol>();
foreach (var server in _servers.Values.Where(s => s.State == LspServerState.Running))
{
try
{
var symbols = await server.SendRequestAsync<Symbol[]>(
"workspace/symbol", new { query });
if (symbols != null) results.AddRange(symbols);
}
catch { /* 继续尝试其他服务器 */ }
}
return results.ToArray();
}
// === 文件同步 ===
public async Task OpenFileAsync(string filePath, string content)
{
var server = await EnsureServerStartedAsync(filePath);
if (server == null) return;
var uri = PathToFileUri(filePath);
if (_openedFiles.Contains(uri)) return; // 已打开
var ext = Path.GetExtension(filePath).ToLowerInvariant();
var languageId = server.ExtensionToLanguage.GetValueOrDefault(ext) ?? "plaintext";
await server.SendNotificationAsync("textDocument/didOpen", new
{
textDocument = new { uri, languageId, version = 1, text = content }
});
_openedFiles.Add(uri);
}
public async Task ChangeFileAsync(string filePath, string content)
{
var server = GetServerForFile(filePath);
if (server?.State != LspServerState.Running)
return; // 先 OpenFile
var uri = PathToFileUri(filePath);
if (!_openedFiles.Contains(uri))
return; // 先 OpenFile
await server.SendNotificationAsync("textDocument/didChange", new
{
textDocument = new { uri, version = 1 },
contentChanges = new[] { new { text = content } }
});
}
public async Task ShutdownAsync()
{
var tasks = _servers.Values
.Where(s => s.State is LspServerState.Running or LspServerState.Error)
.Select(s => s.DisposeAsync().AsTask());
await Task.WhenAll(tasks);
_servers.Clear();
_extensionMap.Clear();
_openedFiles.Clear();
}
private static string PathToFileUri(string path) =>
new Uri(Path.GetFullPath(path)).AbsoluteUri;
}
```
## 11.4 LspDiagnosticRegistry 基线比较
```csharp
/// <summary>
/// LSP 诊断收集与基线对比
/// 对应原始 LSPDiagnosticRegistry.ts
/// </summary>
public sealed class LspDiagnosticRegistry
{
private readonly Dictionary<string, List<Diagnostic>> _baseline = new();
private readonly Dictionary<string, List<Diagnostic>> _current = new();
/// <summary>保存当前诊断为基线(编辑前快照)</summary>
public void SaveBaseline(string filePath)
{
if (_current.TryGetValue(filePath, out var diagnostics))
_baseline[filePath] = new(diagnostics);
}
/// <summary>更新诊断(来自 textDocument/publishDiagnostics 通知)</summary>
public void UpdateDiagnostics(string filePath, IReadOnlyList<Diagnostic> diagnostics)
{
_current[filePath] = diagnostics.ToList();
}
/// <summary>获取新增的诊断(相对于基线)</summary>
public IReadOnlyList<Diagnostic> GetNewDiagnostics(string filePath)
{
var baseline = _baseline.GetValueOrDefault(filePath) ?? [];
var current = _current.GetValueOrDefault(filePath) ?? [];
var baselineSet = new HashSet<Diagnostic>(baseline);
return current.Where(d => !baselineSet.Contains(d)).ToList();
}
/// <summary>清除文件基线</summary>
public void ClearBaseline(string filePath) => _baseline.Remove(filePath);
}
```

View File

@ -0,0 +1,802 @@
# 基础设施设计 — MCP 协议集成
## 文档元数据
- 项目名称: free-code
- 文档类型: 基础设施设计
- 原始代码来源: `../../src/services/mcp/`22个文件
- 原始设计意图: 将 MCP 服务器、工具、资源与认证统一抽象为可管理的 .NET 协议层,并支持多传输、多作用域与自动适配
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-工具系统](../核心模块设计/核心模块设计-工具系统.md) | [原始代码映射](reference/原始代码映射-基础设施.md)
## 设计目标
MCP 协议层负责把外部工具、远程服务、认证流程与本地执行环境统一起来,向上提供稳定的 .NET 抽象。该层不仅要兼容多种 transport还要把 MCP 工具/命令/资源适配为上层工具系统可消费的接口。
## 10.1 IMcpClientManager 接口定义
```csharp
/// <summary>
/// MCP 客户端管理器 — 管理多个 MCP 服务器连接
/// 对应原始 useManageMCPConnections.ts
/// </summary>
public interface IMcpClientManager
{
/// <summary>连接所有配置的 MCP 服务器</summary>
Task ConnectServersAsync(CancellationToken ct = default);
/// <summary>获取所有已连接服务器的工具(适配为 ITool</summary>
Task<IReadOnlyList<ITool>> GetToolsAsync();
/// <summary>获取所有已连接服务器的命令(适配为 ICommand</summary>
Task<IReadOnlyList<ICommand>> GetCommandsAsync();
/// <summary>列出指定服务器的资源</summary>
Task<IReadOnlyList<ServerResource>> ListResourcesAsync(
string? serverName = null, CancellationToken ct = default);
/// <summary>读取指定资源</summary>
Task<ResourceContent> ReadResourceAsync(
string serverName, string resourceUri, CancellationToken ct = default);
/// <summary>断开指定服务器</summary>
Task DisconnectServerAsync(string serverName);
/// <summary>重连指定服务器(用于断线恢复)</summary>
Task ReconnectServerAsync(string serverName);
/// <summary>获取所有服务器连接状态</summary>
IReadOnlyList<MCPServerConnection> GetConnections();
/// <summary>触发认证流程OAuth</summary>
Task AuthenticateServerAsync(string serverName);
/// <summary>重新加载所有配置并重连</summary>
Task ReloadAsync();
}
```
## 10.2 MCPServerConnection 抽象 record
对应原始 `types.ts` 中的 union type。
```csharp
/// <summary>
/// MCP 服务器连接状态 — 替代原始 TypeScript 联合类型
/// 原始: ConnectedMCPServer | FailedMCPServer | NeedsAuthMCPServer | PendingMCPServer | DisabledMCPServer
/// </summary>
public abstract record MCPServerConnection
{
public string Name { get; init; }
public string ConnectionType { get; init; }
public ScopedMcpServerConfig Config { get; init; }
// 类型判别(替代 TypeScript 可辨识联合)
public bool IsConnected => this is Connected;
public bool IsFailed => this is Failed;
public bool NeedsAuth => this is NeedsAuthentication;
public bool IsPending => this is Pending;
public bool IsDisabled => this is Disabled;
public sealed record Connected(
string Name,
ScopedMcpServerConfig Config,
McpClient Client,
ServerCapabilities Capabilities,
ServerInfo? ServerInfo,
string? Instructions,
Func<Task> Cleanup
) : MCPServerConnection { ConnectionType = "connected"; }
public sealed record Failed(
string Name,
ScopedMcpServerConfig Config,
string? Error
) : MCPServerConnection { ConnectionType = "failed"; }
public sealed record NeedsAuthentication(
string Name,
ScopedMcpServerConfig Config
) : MCPServerConnection { ConnectionType = "needs-auth"; }
public sealed record Pending(
string Name,
ScopedMcpServerConfig Config,
int? ReconnectAttempt = null,
int? MaxReconnectAttempts = null
) : MCPServerConnection { ConnectionType = "pending"; }
public sealed record Disabled(
string Name,
ScopedMcpServerConfig Config
) : MCPServerConnection { ConnectionType = "disabled"; }
}
```
## 10.3 ScopedMcpServerConfig 配置层级
对应原始 `types.ts` 中的 Zod schema 定义。
```csharp
/// <summary>
/// MCP 服务器配置 — 替代原始 8 种 Zod schema
/// 使用 FluentValidation 进行校验
/// </summary>
public abstract record ScopedMcpServerConfig
{
public required ConfigScope Scope { get; init; }
public string? PluginSource { get; init; }
}
public record StdioServerConfig : ScopedMcpServerConfig
{
public string Command { get; init; } = "";
public IReadOnlyList<string> Args { get; init; } = [];
public IReadOnlyDictionary<string, string>? Env { get; init; }
}
public record SseServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
public string? HeadersHelper { get; init; }
public McpOAuthConfig? OAuth { get; init; }
}
public record SseIdeServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public required string IdeName { get; init; }
public bool IdeRunningInWindows { get; init; }
}
public record WsIdeServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public required string IdeName { get; init; }
public string? AuthToken { get; init; }
public bool IdeRunningInWindows { get; init; }
}
public record HttpServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
public string? HeadersHelper { get; init; }
public McpOAuthConfig? OAuth { get; init; }
}
public record WebSocketServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public IReadOnlyDictionary<string, string>? Headers { get; init; }
public string? HeadersHelper { get; init; }
}
public record SdkServerConfig : ScopedMcpServerConfig
{
public required string ServerName { get; init; }
}
public record ClaudeAiProxyServerConfig : ScopedMcpServerConfig
{
public required string Url { get; init; }
public required string Id { get; init; }
}
public enum ConfigScope
{
Local, User, Project, Dynamic, Enterprise, ClaudeAi, Managed
}
public record McpOAuthConfig
{
public string? ClientId { get; init; }
public int? CallbackPort { get; init; }
public string? AuthServerMetadataUrl { get; init; }
public bool Xaa { get; init; }
}
```
## 10.4 传输层实现
对应原始 `@modelcontextprotocol/sdk` 中的多种传输 + 自定义 WebSocketTransport。
```csharp
/// <summary>
/// MCP 传输层抽象 — JSON-RPC 2.0 over various transports
/// </summary>
public interface IMcpTransport : IAsyncDisposable
{
Task ConnectAsync(CancellationToken ct = default);
Task SendAsync(JsonRpcMessage message, CancellationToken ct = default);
IAsyncEnumerable<JsonRpcMessage> ListenAsync(CancellationToken ct = default);
Task CloseAsync();
bool IsConnected { get; }
}
/// <summary>
/// Stdio 传输 — 子进程 stdin/stdout
/// 对应原始 StdioClientTransport
/// </summary>
public sealed class StdioTransport : IMcpTransport
{
private readonly Process _process;
private readonly Channel<JsonRpcMessage> _incoming = Channel.CreateUnbounded<JsonRpcMessage>();
public StdioTransport(StdioServerConfig config, string workingDirectory)
{
var psi = new ProcessStartInfo
{
FileName = config.Command,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory,
};
foreach (var arg in config.Args)
psi.ArgumentList.Add(arg);
// 合并环境变量
if (config.Env != null)
foreach (var (key, value) in config.Env)
psi.Environment[key] = value;
_process = new Process { StartInfo = psi };
}
public async Task ConnectAsync(CancellationToken ct = default)
{
_process.Start();
// 后台读取 stdout → 解析 JSON-RPC 消息 → 写入 channel
_ = Task.Run(async () =>
{
using var reader = new StreamReader(_process.StandardOutput.BaseStream,
System.Text.Encoding.UTF8);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrEmpty(line)) continue;
var message = JsonSerializer.Deserialize<JsonRpcMessage>(line);
if (message != null)
await _incoming.Writer.WriteAsync(message, ct);
}
}, ct);
}
public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(message);
await _process.StandardInput.WriteLineAsync(json.AsMemory(), ct);
await _process.StandardInput.FlushAsync();
}
public IAsyncEnumerable<JsonRpcMessage> ListenAsync(CancellationToken ct = default)
=> _incoming.Reader.ReadAllAsync(ct);
public bool IsConnected => !_process.HasExited;
public async ValueTask DisposeAsync()
{
if (!_process.HasExited)
{
_process.StandardInput.Close();
if (!_process.WaitForExit(5000))
_process.Kill(entireProcessTree: true);
}
_incoming.Writer.TryComplete();
}
}
/// <summary>
/// SSE 传输 — HTTP Server-Sent Events
/// 对应原始 SSEClientTransport
/// </summary>
public sealed class SseTransport : IMcpTransport
{
private readonly HttpClient _httpClient;
private readonly string _url;
private readonly Channel<JsonRpcMessage> _incoming = Channel.CreateUnbounded<JsonRpcMessage>();
private string? _messageEndpoint; // 服务器提供的 POST 端点
public async Task ConnectAsync(CancellationToken ct = default)
{
// GET SSE 流 → 解析 endpoint 事件 → 开始监听
var response = await _httpClient.GetAsync(_url, HttpCompletionOption.ResponseHeadersRead, ct);
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrEmpty(line)) continue;
if (line.StartsWith("event: endpoint"))
{
var dataLine = await reader.ReadLineAsync(ct);
if (dataLine?.StartsWith("data: ") == true)
_messageEndpoint = dataLine[6..];
}
else if (line.StartsWith("data: "))
{
var json = line[6..];
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json);
if (message != null)
await _incoming.Writer.WriteAsync(message, ct);
}
}
}
public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default)
{
if (_messageEndpoint == null)
throw new InvalidOperationException("SSE transport not connected");
var content = JsonContent.Create(message);
await _httpClient.PostAsync(_messageEndpoint, content, ct);
}
public IAsyncEnumerable<JsonRpcMessage> ListenAsync(CancellationToken ct = default)
=> _incoming.Reader.ReadAllAsync(ct);
public bool IsConnected => _messageEndpoint != null;
}
/// <summary>
/// Streamable HTTP 传输 — MCP 2025-03-26 规范
/// 对应原始 StreamableHTTPClientTransport
/// POST 发送消息,响应可能是 JSON 或 SSE
/// </summary>
public sealed class StreamableHttpTransport : IMcpTransport
{
private readonly HttpClient _httpClient;
private readonly string _url;
private readonly Channel<JsonRpcMessage> _incoming = Channel.CreateUnbounded<JsonRpcMessage>();
private string? _sessionId; // 服务器分配的 Mcp-Session-Id
public async Task ConnectAsync(CancellationToken ct = default)
{
// 发送 initialize JSON-RPC 请求
var initRequest = new JsonRpcRequest
{
Id = "init-1",
Method = "initialize",
Params = new { protocolVersion = "2025-03-26", capabilities = new { }, clientInfo = new { name = "free-code", version = "1.0" } }
};
var response = await SendInternalAsync(initRequest, ct);
// 从响应头提取 session ID
if (response.Headers.Contains("Mcp-Session-Id"))
_sessionId = response.Headers.GetValues("Mcp-Session-Id").First();
}
private async Task<HttpResponseMessage> SendInternalAsync(
JsonRpcRequest request, CancellationToken ct)
{
using var content = JsonContent.Create(request);
var httpReq = new HttpRequestMessage(HttpMethod.Post, _url) { Content = content };
// MCP Streamable HTTP 规范: Accept 必须包含 application/json 和 text/event-stream
httpReq.Headers.Accept.Add(new("application/json"));
httpReq.Headers.Accept.Add(new("text/event-stream"));
if (_sessionId != null)
httpReq.Headers.Add("Mcp-Session-Id", _sessionId);
return await _httpClient.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct);
}
// ... SendAsync 和 ListenAsync 实现类似 SSE 但处理 JSON/SSE 双模式响应
}
/// <summary>
/// WebSocket 传输 — 对应原始 WebSocketTransport (自定义实现)
/// </summary>
public sealed class WebSocketTransport : IMcpTransport
{
private readonly ClientWebSocket _webSocket;
private readonly string _url;
private readonly Channel<JsonRpcMessage> _incoming = Channel.CreateUnbounded<JsonRpcMessage>();
public async Task ConnectAsync(CancellationToken ct = default)
{
await _webSocket.ConnectAsync(new Uri(_url), ct);
_ = ReceiveLoopAsync(ct);
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buffer = new byte[8192];
while (_webSocket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await _webSocket.ReceiveAsync(buffer, ct);
await ms.WriteAsync(buffer, 0, result.Count, ct);
} while (!result.EndOfMessage);
var json = System.Text.Encoding.UTF8.GetString(ms.ToArray());
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json);
if (message != null)
await _incoming.Writer.WriteAsync(message, ct);
}
}
public async Task SendAsync(JsonRpcMessage message, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(message);
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
await _webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
public IAsyncEnumerable<JsonRpcMessage> ListenAsync(CancellationToken ct = default)
=> _incoming.Reader.ReadAllAsync(ct);
public bool IsConnected => _webSocket.State == WebSocketState.Open;
}
/// <summary>
/// 进程内传输 — 对应原始 InProcessTransport (linked transport pair)
/// 用于 Chrome/Computer Use MCP 服务器在同一进程运行
/// </summary>
public sealed class InProcessTransport : IMcpTransport
{
private readonly Channel<JsonRpcMessage> _serverToClient = Channel.CreateUnbounded<JsonRpcMessage>();
private readonly Channel<JsonRpcMessage> _clientToServer = Channel.CreateUnbounded<JsonRpcMessage>();
// 创建一对连接的传输(客户端 + 服务器各一个)
public static (InProcessTransport client, InProcessTransport server) CreateLinkedPair()
{
var client = new InProcessTransport();
var server = new InProcessTransport();
// 交叉连接 channel: client writes → server reads, vice versa
client._outgoing = server._serverToClient.Writer;
server._outgoing = client._clientToServer.Writer;
return (client, server);
}
private ChannelWriter<JsonRpcMessage>? _outgoing;
public IAsyncEnumerable<JsonRpcMessage> ListenAsync(CancellationToken ct = default)
=> _serverToClient.Reader.ReadAllAsync(ct);
// ...
}
```
## 10.5 McpClient 核心
```csharp
/// <summary>
/// JSON-RPC 2.0 MCP 客户端
/// 对应原始 @modelcontextprotocol/sdk Client
/// </summary>
public sealed class McpClient
{
private readonly IMcpTransport _transport;
private int _requestId;
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonRpcResponse>> _pending = new();
public ServerCapabilities? Capabilities { get; private set; }
public ServerInfo? ServerInfo { get; private set; }
public string? Instructions { get; private set; }
public async Task ConnectAsync(CancellationToken ct = default)
{
await _transport.ConnectAsync(ct);
// 发送 initialize → 接收 response
var response = await SendRequestAsync("initialize", new
{
protocolVersion = "2025-03-26",
capabilities = new { roots = new { }, elicitation = new { } },
clientInfo = new { name = "free-code", version = "1.0" }
}, ct);
Capabilities = DeserializeCapabilities(response);
ServerInfo = DeserializeServerInfo(response);
Instructions = DeserializeInstructions(response);
// 发送 initialized 通知
await SendNotificationAsync("notifications/initialized", null, ct);
}
public async Task<ListToolsResult> ListToolsAsync(CancellationToken ct = default)
{
var response = await SendRequestAsync("tools/list", new { }, ct);
return Deserialize<ListToolsResult>(response);
}
public async Task<CallToolResult> CallToolAsync(
string toolName, JsonElement? arguments = null, CancellationToken ct = default)
{
var response = await SendRequestAsync("tools/call", new
{
name = toolName,
arguments
}, ct);
return Deserialize<CallToolResult>(response);
}
public async Task<ListResourcesResult> ListResourcesAsync(CancellationToken ct = default)
{
var response = await SendRequestAsync("resources/list", new { }, ct);
return Deserialize<ListResourcesResult>(response);
}
public async Task<ReadResourceResult> ReadResourceAsync(
string uri, CancellationToken ct = default)
{
var response = await SendRequestAsync("resources/read", new { uri }, ct);
return Deserialize<ReadResourceResult>(response);
}
public async Task<ListPromptsResult> ListPromptsAsync(CancellationToken ct = default)
{
var response = await SendRequestAsync("prompts/list", new { }, ct);
return Deserialize<ListPromptsResult>(response);
}
private async Task<JsonElement> SendRequestAsync(
string method, object? @params, CancellationToken ct)
{
var id = Interlocked.Increment(ref _requestId).ToString();
var tcs = new TaskCompletionSource<JsonRpcResponse>();
_pending[id] = tcs;
var message = new JsonRpcRequest { Id = id, Method = method, Params = @params };
await _transport.SendAsync(message, ct);
using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
var response = await tcs.Task;
return response.Result;
}
private async Task SendNotificationAsync(
string method, object? @params, CancellationToken ct)
{
var message = new JsonRpcNotification { Method = method, Params = @params };
await _transport.SendAsync(message, ct);
}
}
```
## 10.6 McpClientManager 实现
```csharp
public class McpClientManager : IMcpClientManager
{
private readonly IServiceProvider _services;
private readonly IConfiguration _config;
private readonly IFeatureFlagService _features;
private readonly IAppStateStore _stateStore;
private readonly ILogger<McpClientManager> _logger;
private readonly ConcurrentDictionary<string, MCPServerConnection> _connections = new();
/// <summary>
/// 连接所有配置的 MCP 服务器
/// 对应原始 useManageMCPConnections.ts 的 effect
/// </summary>
public async Task ConnectServersAsync(CancellationToken ct = default)
{
var configs = await LoadAllServerConfigsAsync();
var localServers = configs.Where(c => IsLocalServer(c.Value)).ToList();
var remoteServers = configs.Where(c => !IsLocalServer(c.Value)).ToList();
// 本地服务器: 批量连接 (默认 concurrency: 3)
var localBatchSize = GetLocalBatchSize(); // MCP_SERVER_CONNECTION_BATCH_SIZE
await Parallel.ForEachAsync(localServers,
new ParallelOptions { MaxDegreeOfParallelism = localBatchSize, CancellationToken = ct },
async (kvp, _) =>
{
var connection = await ConnectToServerAsync(kvp.Key, kvp.Value, ct);
_connections[kvp.Key] = connection;
UpdateState();
});
// 远程服务器: 批量连接 (默认 concurrency: 20)
var remoteBatchSize = GetRemoteBatchSize(); // MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE
await Parallel.ForEachAsync(remoteServers,
new ParallelOptions { MaxDegreeOfParallelism = remoteBatchSize, CancellationToken = ct },
async (kvp, _) =>
{
var connection = await ConnectToServerAsync(kvp.Key, kvp.Value, ct);
_connections[kvp.Key] = connection;
UpdateState();
});
}
private async Task<MCPServerConnection> ConnectToServerAsync(
string name, ScopedMcpServerConfig config, CancellationToken ct)
{
try
{
// 1. 创建传输层
var transport = CreateTransport(name, config);
// 2. 创建客户端并连接
var client = new McpClient(transport);
var timeout = GetConnectionTimeout(); // MCP_TIMEOUT 环境变量, 默认 30s
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
await client.ConnectAsync(timeoutCts.Token);
// 3. 获取能力、工具、资源
var tools = await client.ListToolsAsync(ct);
var capabilities = client.Capabilities!;
// 4. 截断过长的 instructions (MAX_MCP_DESCRIPTION_LENGTH = 2048)
var instructions = client.Instructions;
if (instructions?.Length > 2048)
instructions = instructions[..2048] + "… [truncated]";
return new MCPServerConnection.Connected(name, config, client,
capabilities, client.ServerInfo, instructions,
async () => await transport.CloseAsync());
}
catch (UnauthorizedException)
{
// SSE/HTTP/claudeai-proxy: 需要认证
return new MCPServerConnection.NeedsAuthentication(name, config);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MCP server {Name} connection failed", name);
return new MCPServerConnection.Failed(name, config, ex.Message);
}
}
private IMcpTransport CreateTransport(string name, ScopedMcpServerConfig config)
{
return config switch
{
StdioServerConfig stdio => new StdioTransport(stdio,
Directory.GetCurrentDirectory()),
SseServerConfig sse => new SseTransport(
CreateHttpClient(sse.Headers), sse.Url),
SseIdeServerConfig sseIde => new SseTransport(
new HttpClient(), sseIde.Url),
HttpServerConfig http => new StreamableHttpTransport(
CreateHttpClient(http.Headers), http.Url),
WsIdeServerConfig wsIde => new WebSocketTransport(wsIde.Url, wsIde.AuthToken),
WebSocketServerConfig ws => new WebSocketTransport(ws.Url, null, ws.Headers),
ClaudeAiProxyServerConfig proxy => new StreamableHttpTransport(
CreateProxyHttpClient(), proxy.Url),
_ => throw new InvalidOperationException($"Unsupported server type: {config}")
};
}
/// <summary>
/// 获取所有工具并适配为 ITool 接口
/// 对应原始 MCPTool 适配器
/// </summary>
public async Task<IReadOnlyList<ITool>> GetToolsAsync()
{
var tools = new List<ITool>();
foreach (var connection in _connections.Values)
{
if (connection is MCPServerConnection.Connected connected)
{
var mcpTools = await connected.Client.ListToolsAsync();
foreach (var tool in mcpTools.Tools)
{
tools.Add(new McpToolWrapper(connected.Name, tool, connected.Client));
}
}
}
return tools;
}
/// <summary>更新 AppState 中的 MCP 状态</summary>
private void UpdateState()
{
_stateStore.Update(state => state with
{
Mcp = state.Mcp with
{
Clients = _connections.Values.ToList(),
Tools = GetToolsFromConnections(),
}
});
}
private static bool IsLocalServer(ScopedMcpServerConfig config) =>
config is StdioServerConfig or SdkServerConfig;
}
/// <summary>
/// MCP 工具包装器 — 将 MCP tool 适配为 ITool 接口
/// 对应原始 MCPTool.ts
/// </summary>
public sealed class McpToolWrapper : ITool<JsonElement, JsonElement>
{
private readonly string _serverName;
private readonly McpToolDefinition _definition;
private readonly McpClient _client;
public string Name => $"mcp__{_serverName}__{_definition.Name}";
public ToolCategory Category => ToolCategory.Mcp;
public async Task<ToolResult<JsonElement>> ExecuteAsync(
JsonElement input, ToolExecutionContext context, CancellationToken ct)
{
var timeout = GetMcpToolTimeout(); // MCP_TOOL_TIMEOUT, 默认 ~27.8h
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
var result = await _client.CallToolAsync(_definition.Name, input, timeoutCts.Token);
// 处理二进制内容 → 持久化到文件
if (result.HasBinaryContent)
{
var persisted = await PersistBinaryContentAsync(result);
return new ToolResult<JsonElement>(persisted);
}
return new ToolResult<JsonElement>(result.Content);
}
catch (McpAuthException)
{
// 标记服务器需要认证
throw;
}
}
public JsonElement GetInputSchema() => _definition.InputSchema;
public bool IsReadOnly(JsonElement input) => !_definition.HasDestructiveBehavior;
public bool IsConcurrencySafe(JsonElement input) => false;
}
```
## 10.7 McpAuthService
```csharp
/// <summary>
/// MCP OAuth 认证 — 对应原始 auth.ts 中的 ClaudeAuthProvider
/// </summary>
public sealed class McpAuthService
{
private readonly IdentityModel.OidcClient _oidcClient;
private readonly ISecureTokenStorage _tokenStorage; // macOS Keychain / credential manager
/// <summary>
/// 执行 OAuth 授权流程: 发现 → 浏览器授权 → code 交换 → token 存储
/// </summary>
public async Task<McpOAuthTokens> AuthorizeAsync(
string serverName, McpOAuthConfig config, CancellationToken ct)
{
// 1. 检查缓存的 token
var cached = await _tokenStorage.GetAsync($"mcp-{serverName}");
if (cached != null && !cached.IsExpired)
return cached;
// 2. 发现授权服务器元数据
var metadata = await DiscoverAuthorizationServerAsync(config.AuthServerMetadataUrl!, ct);
// 3. 启动本地 HTTP 监听器
var callbackPort = config.CallbackPort ?? GetAvailablePort();
using var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{callbackPort}/");
listener.Start();
// 4. 构建授权 URL → 打开浏览器
var authUrl = BuildAuthorizationUrl(metadata, callbackPort);
OpenBrowser(authUrl);
// 5. 等待回调 → 提取 code
var context = await listener.GetContextAsync();
var code = context.Request.QueryString["code"];
// 6. 交换 token
var tokens = await ExchangeCodeAsync(metadata, code, callbackPort, ct);
await _tokenStorage.SetAsync($"mcp-{serverName}", tokens);
return tokens;
}
}
```

View File

@ -0,0 +1,321 @@
# 基础设施设计 — 后台任务管理
## 文档元数据
- 项目名称: free-code
- 文档类型: 基础设施设计
- 原始代码来源: `../../src/tasks/`10个文件
- 原始设计意图: 将 Shell、Agent、远程会话、工作流、监控与记忆合并任务统一纳入后台调度和状态同步框架
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [服务子系统设计-会话记忆](../服务子系统设计/服务子系统设计-会话记忆与上下文.md)
## 设计目标
后台任务管理层统一承载本地 shell、agent、远程 agent、监控与工作流型任务并保证可调度、可观测与可取消。它必须和全局状态存储、查询引擎、桥接层协同工作且在宿主关闭时能优雅终止。
## 13.1 BackgroundTask 任务层次
```csharp
/// <summary>
/// 后台任务基类 — 所有任务类型的公共抽象
/// 对应原始 types.ts 中的 TaskState 联合类型
/// </summary>
public abstract record BackgroundTask
{
public required string TaskId { get; init; }
public abstract BackgroundTaskType TaskType { get; }
public TaskStatus Status { get; set; } = TaskStatus.Pending;
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
public bool IsBackgrounded { get; set; } = true;
}
public enum BackgroundTaskType
{
LocalShell,
LocalAgent,
RemoteAgent,
InProcessTeammate,
LocalWorkflow,
MonitorMcp,
Dream
}
public enum TaskStatus { Pending, Running, Completed, Failed, Stopped }
// === 具体任务类型 ===
/// <summary>
/// 本地 Shell 任务 — 后台 bash 命令
/// 对应原始 LocalShellTask
/// </summary>
public sealed record LocalShellTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.LocalShell;
public required string Command { get; init; }
public ProcessStartInfo? ProcessStartInfo { get; init; }
public string? Stdout { get; set; }
public string? Stderr { get; set; }
public int? ExitCode { get; set; }
}
/// <summary>
/// 本地 Agent 任务 — 子代理forked process 或 worktree 隔离)
/// 对应原始 LocalAgentTask
/// </summary>
public sealed record LocalAgentTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.LocalAgent;
public required string Prompt { get; init; }
public string? Model { get; init; }
public string? AgentType { get; init; } // explore, librarian, oracle, metis, momus
public string? WorkingDirectory { get; init; }
public List<Message> Messages { get; } = new();
}
/// <summary>
/// 远程 Agent 任务 — claude.ai 上的远程会话
/// 对应原始 RemoteAgentTask
/// </summary>
public sealed record RemoteAgentTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.RemoteAgent;
public required string SessionUrl { get; init; }
public string? Plan { get; set; }
public string? Status { get; set; }
}
/// <summary>
/// 进程内 Teammate 任务 — 协作代理
/// 对应原始 InProcessTeammateTask
/// </summary>
public sealed record InProcessTeammateTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.InProcessTeammate;
public required string AgentName { get; init; }
public string? AgentType { get; init; }
public string? Color { get; init; }
public required string WorkingDirectory { get; init; }
}
/// <summary>
/// 本地工作流任务 — 多步骤工作流
/// 对应原始 LocalWorkflowTask
/// </summary>
public sealed record LocalWorkflowTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.LocalWorkflow;
public required string WorkflowName { get; init; }
public required List<WorkflowStep> Steps { get; init; }
public int CurrentStepIndex { get; set; }
}
/// <summary>
/// MCP SSE 监控任务 — 监控 MCP 服务器连接状态
/// 对应原始 MonitorMcpTask
/// </summary>
public sealed record MonitorMcpTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.MonitorMcp;
public required string ServerName { get; init; }
public int ReconnectAttempt { get; set; }
}
/// <summary>
/// Dream 任务 — 后台记忆合并
/// 对应原始 DreamTask
/// </summary>
public sealed record DreamTask : BackgroundTask
{
public override BackgroundTaskType TaskType => BackgroundTaskType.Dream;
public required string TriggerReason { get; init; } // time | session_count
}
```
## 13.2 IBackgroundTaskManager 接口
```csharp
/// <summary>
/// 后台任务管理器 — Channel-based 任务调度
/// 对应原始 task 工具 + 后台任务 UI 指示器
/// </summary>
public interface IBackgroundTaskManager
{
/// <summary>创建 Shell 任务</summary>
Task<LocalShellTask> CreateShellTaskAsync(string command, ProcessStartInfo psi);
/// <summary>创建 Agent 任务</summary>
Task<LocalAgentTask> CreateAgentTaskAsync(string prompt, string? agentType, string? model);
/// <summary>创建远程 Agent 任务</summary>
Task<RemoteAgentTask> CreateRemoteAgentTaskAsync(string sessionUrl);
/// <summary>创建 Dream 任务</summary>
Task<DreamTask> CreateDreamTaskAsync(string triggerReason);
/// <summary>停止任务</summary>
Task StopTaskAsync(string taskId);
/// <summary>获取任务输出</summary>
Task<string?> GetTaskOutputAsync(string taskId);
/// <summary>列出所有任务</summary>
IReadOnlyList<BackgroundTask> ListTasks();
/// <summary>获取指定任务</summary>
BackgroundTask? GetTask(string taskId);
/// <summary>任务状态变更事件</summary>
event EventHandler<TaskStateChangedEventArgs>? TaskStateChanged;
}
```
## 13.3 BackgroundTaskManager 实现
```csharp
public class BackgroundTaskManager : IBackgroundTaskManager, IHostedService
{
private readonly Channel<BackgroundTask> _taskChannel = Channel.CreateUnbounded<BackgroundTask>();
private readonly ConcurrentDictionary<string, BackgroundTask> _tasks = new();
private readonly IAppStateStore _stateStore;
private readonly IServiceProvider _services;
private readonly ILogger<BackgroundTaskManager> _logger;
private readonly CancellationTokenSource _shutdownCts = new();
public event EventHandler<TaskStateChangedEventArgs>? TaskStateChanged;
public async Task StartAsync(CancellationToken ct)
{
// 启动任务调度循环
_ = Task.Run(() => DispatchLoopAsync(_shutdownCts.Token), _shutdownCts.Token);
await Task.CompletedTask;
}
/// <summary>
/// 任务调度循环 — 从 channel 读取任务并执行
/// </summary>
private async Task DispatchLoopAsync(CancellationToken ct)
{
await foreach (var task in _taskChannel.Reader.ReadAllAsync(ct))
{
_ = Task.Run(async () =>
{
task.Status = TaskStatus.Running;
task.StartedAt = DateTime.UtcNow;
UpdateState();
TaskStateChanged?.Invoke(this, new(task.TaskId, task.Status));
try
{
await ExecuteTaskAsync(task, ct);
task.Status = TaskStatus.Completed;
}
catch (OperationCanceledException)
{
task.Status = TaskStatus.Stopped;
}
catch (Exception ex)
{
task.Status = TaskStatus.Failed;
task.ErrorMessage = ex.Message;
_logger.LogError(ex, "Task {TaskId} failed", task.TaskId);
}
finally
{
task.CompletedAt = DateTime.UtcNow;
UpdateState();
TaskStateChanged?.Invoke(this, new(task.TaskId, task.Status));
}
}, ct);
}
}
private Task ExecuteTaskAsync(BackgroundTask task, CancellationToken ct) => task switch
{
LocalShellTask shell => ExecuteShellTaskAsync(shell, ct),
LocalAgentTask agent => ExecuteAgentTaskAsync(agent, ct),
RemoteAgentTask remote => ExecuteRemoteAgentTaskAsync(remote, ct),
DreamTask dream => ExecuteDreamTaskAsync(dream, ct),
MonitorMcpTask monitor => ExecuteMonitorTaskAsync(monitor, ct),
LocalWorkflowTask workflow => ExecuteWorkflowTaskAsync(workflow, ct),
InProcessTeammateTask teammate => ExecuteTeammateTaskAsync(teammate, ct),
_ => Task.CompletedTask
};
private async Task ExecuteShellTaskAsync(LocalShellTask task, CancellationToken ct)
{
using var process = new Process { StartInfo = task.ProcessStartInfo! };
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
var stderr = await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
task.Stdout = stdout;
task.Stderr = stderr;
task.ExitCode = process.ExitCode;
}
private async Task ExecuteAgentTaskAsync(LocalAgentTask task, CancellationToken ct)
{
// 使用 QueryEngine 在隔离 context 中执行子代理查询
var engine = _services.GetRequiredService<IQueryEngine>();
await foreach (var msg in engine.SubmitMessageAsync(task.Prompt,
new(Model: task.Model), ct))
{
// 收集消息到 task.Messages
}
}
private async Task ExecuteDreamTaskAsync(DreamTask task, CancellationToken ct)
{
var dreamService = _services.GetRequiredService<IAutoDreamService>();
await dreamService.RunDreamCycleAsync(ct);
}
public Task<LocalShellTask> CreateShellTaskAsync(string command, ProcessStartInfo psi)
{
var task = new LocalShellTask
{
TaskId = Guid.NewGuid().ToString("N")[..8],
Command = command,
ProcessStartInfo = psi,
};
_tasks[task.TaskId] = task;
_taskChannel.Writer.TryWrite(task);
UpdateState();
return Task.FromResult(task);
}
public Task StopTaskAsync(string taskId)
{
if (_tasks.TryGetValue(taskId, out var task) && task.Status == TaskStatus.Running)
{
task.Status = TaskStatus.Stopped;
// 进程级取消由具体执行器处理
}
return Task.CompletedTask;
}
public IReadOnlyList<BackgroundTask> ListTasks() => _tasks.Values.ToList();
public BackgroundTask? GetTask(string taskId) => _tasks.GetValueOrDefault(taskId);
/// <summary>更新 AppState 中的任务状态</summary>
private void UpdateState()
{
_stateStore.Update(state =>
{
var tasks = state.Tasks.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var task in _tasks.Values)
tasks[task.TaskId] = task;
return state with { Tasks = tasks };
});
}
public async Task StopAsync(CancellationToken ct)
{
_shutdownCts.Cancel();
_taskChannel.Writer.TryComplete();
foreach (var task in _tasks.Values.Where(t => t.Status == TaskStatus.Running))
await StopTaskAsync(task.TaskId);
}
}
```

View File

@ -0,0 +1,253 @@
# 基础设施设计 — 状态管理
## 文档元数据
- 项目名称: free-code
- 文档类型: 基础设施设计
- 原始代码来源: `../../src/state/`5个文件
- 原始设计意图: 用不可变全局状态串联 MCP、插件、通知、后台任务、桥接与推理状态并通过事件驱动方式向 UI 广播更新
- 交叉引用: [基础设施设计总览](基础设施设计.md) | [核心模块设计-查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
## 设计目标
状态管理层负责承载应用运行态、MCP 状态、插件状态、通知状态与推理/猜测状态,并通过不可变更新维持可预测性。它是基础设施层的共享事实来源,供 UI、协议层和后台任务共同读取。
## 14.1 AppState 不可变 Record
```csharp
/// <summary>
/// 应用全局状态 — 不可变 record
/// 对应原始 AppState 类型 (AppStateStore.ts 中 ~450 行类型定义)
/// </summary>
public sealed record AppState
{
// === 核心状态 ===
public SettingsJson Settings { get; init; } = new();
public bool Verbose { get; init; }
public ModelSetting MainLoopModel { get; init; }
public ModelSetting MainLoopModelForSession { get; init; }
public string? StatusLineText { get; init; }
public ExpandedView ExpandedView { get; init; } = ExpandedView.None;
public bool IsBriefOnly { get; init; }
// === 权限 ===
public ToolPermissionContext ToolPermissionContext { get; init; } = new();
public string? Agent { get; init; }
public bool KairosEnabled { get; init; }
// === UI 状态 ===
public int SelectedIPAgentIndex { get; init; } = -1;
public int CoordinatorTaskIndex { get; init; } = -1;
public ViewSelectionMode ViewSelectionMode { get; init; } = ViewSelectionMode.None;
public FooterItem? FooterSelection { get; init; }
// === 后台任务 ===
public IReadOnlyDictionary<string, BackgroundTask> Tasks { get; init; } = new Dictionary<string, BackgroundTask>();
public string? ForegroundedTaskId { get; init; }
public string? ViewingAgentTaskId { get; init; }
public IReadOnlyDictionary<string, AgentId> AgentNameRegistry { get; init; } = new Dictionary<string, AgentId>();
// === MCP ===
public McpState Mcp { get; init; } = new();
// === 插件 ===
public PluginState Plugins { get; init; } = new();
// === 远程/桥接 ===
public string? RemoteSessionUrl { get; init; }
public RemoteConnectionStatus RemoteConnectionStatus { get; init; } = RemoteConnectionStatus.Connecting;
public int RemoteBackgroundTaskCount { get; init; }
// === 桥接 ===
public bool ReplBridgeEnabled { get; init; }
public bool ReplBridgeExplicit { get; init; }
public bool ReplBridgeOutboundOnly { get; init; }
public bool ReplBridgeConnected { get; init; }
public bool ReplBridgeSessionActive { get; init; }
public bool ReplBridgeReconnecting { get; init; }
public string? ReplBridgeConnectUrl { get; init; }
public string? ReplBridgeSessionUrl { get; init; }
public string? ReplBridgeEnvironmentId { get; init; }
public string? ReplBridgeSessionId { get; init; }
public string? ReplBridgeError { get; init; }
public string? ReplBridgeInitialName { get; init; }
public bool ShowRemoteCallout { get; init; }
// === 同伴 ===
public string? CompanionReaction { get; init; }
public long? CompanionPetAt { get; init; }
// === 通知 ===
public NotificationState Notifications { get; init; } = new();
// === 记忆 ===
public AgentDefinitionsResult AgentDefinitions { get; init; } = new();
// === 文件历史 ===
public FileHistoryState FileHistory { get; init; } = new();
// === 归属 ===
public AttributionState Attribution { get; init; } = new();
// === TODO ===
public IReadOnlyDictionary<string, TodoList> Todos { get; init; } = new Dictionary<string, TodoList>();
// === 推测 ===
public SpeculationState Speculation { get; init; } = SpeculationState.Idle;
public long SpeculationSessionTimeSavedMs { get; init; }
// === 其他 ===
public bool? ThinkingEnabled { get; init; }
public bool PromptSuggestionEnabled { get; init; }
public EffortValue? EffortValue { get; init; }
public bool FastMode { get; init; }
public int AuthVersion { get; init; }
public IReadOnlySet<string> ActiveOverlays { get; init; } = new HashSet<string>();
}
// === 嵌套状态类型 ===
public sealed record McpState
{
public IReadOnlyList<MCPServerConnection> Clients { get; init; } = [];
public IReadOnlyList<ITool> Tools { get; init; } = [];
public IReadOnlyList<ICommand> Commands { get; init; } = [];
public IReadOnlyDictionary<string, List<ServerResource>> Resources { get; init; } = new Dictionary<string, List<ServerResource>>();
public int PluginReconnectKey { get; init; }
}
public sealed record PluginState
{
public IReadOnlyList<LoadedPlugin> Enabled { get; init; } = [];
public IReadOnlyList<LoadedPlugin> Disabled { get; init; } = [];
public IReadOnlyList<ICommand> Commands { get; init; } = [];
public IReadOnlyList<PluginError> Errors { get; init; } = [];
public PluginInstallationStatus InstallationStatus { get; init; } = new();
public bool NeedsRefresh { get; init; }
}
public sealed record NotificationState
{
public Notification? Current { get; init; }
public IReadOnlyList<Notification> Queue { get; init; } = [];
}
public record SpeculationState
{
public static readonly SpeculationState Idle = new() { Status = "idle" };
public string Status { get; init; } = "idle";
}
```
## 14.2 IAppStateStore 接口与 AppStateStore 实现
```csharp
/// <summary>
/// 应用状态存储 — 事件驱动不可变状态管理
/// 对应原始 Store<T> + AppStateStore
/// 模式: Redux/Elm 风格 (updater 函数 + 变更通知)
/// </summary>
public interface IAppStateStore
{
/// <summary>获取当前状态(不可变快照)</summary>
AppState GetState();
/// <summary>更新状态(通过 updater 函数)</summary>
void Update(Func<AppState, AppState> updater);
/// <summary>订阅状态变更</summary>
IDisposable Subscribe(Action<AppState> listener);
/// <summary>状态变更事件 (C# event 模式)</summary>
event EventHandler<StateChangedEventArgs>? StateChanged;
}
public sealed class AppStateStore : IAppStateStore
{
private AppState _state;
private readonly object _lock = new();
private readonly List<Action<AppState>> _listeners = new();
private readonly Action<AppState, AppState>? _onChangeCallback;
public event EventHandler<StateChangedEventArgs>? StateChanged;
public AppStateStore()
{
_state = CreateDefaultState();
}
public AppState GetState()
{
lock (_lock) return _state;
}
public void Update(Func<AppState, AppState> updater)
{
AppState prev, next;
lock (_lock)
{
prev = _state;
next = updater(prev);
if (ReferenceEquals(next, prev)) return; // 无变更
_state = next;
}
// 通知变更
_onChangeCallback?.Invoke(next, prev);
StateChanged?.Invoke(this, new StateChangedEventArgs(prev, next));
// 通知订阅者
foreach (var listener in _listeners.ToArray())
listener(next);
}
public IDisposable Subscribe(Action<AppState> listener)
{
lock (_lock) _listeners.Add(listener);
return new Subscription(() =>
{
lock (_lock) _listeners.Remove(listener);
});
}
private static AppState CreateDefaultState() => new AppState();
private sealed class Subscription : IDisposable
{
private readonly Action _unsubscribe;
public Subscription(Action unsubscribe) => _unsubscribe = unsubscribe;
public void Dispose() => _unsubscribe();
}
}
public sealed record StateChangedEventArgs(AppState OldState, AppState NewState);
```
## 14.3 StateSelectors
```csharp
/// <summary>
/// 状态选择器 — 对应原始 selectors.ts
/// 从 AppState 中派生计算值,避免在组件中重复逻辑
/// </summary>
public static class StateSelectors
{
public static bool IsBridgeReady(this AppState state) =>
state.ReplBridgeEnabled && state.ReplBridgeConnected;
public static bool IsBridgeConnected(this AppState state) =>
state.ReplBridgeEnabled && state.ReplBridgeSessionActive;
public static IReadOnlyList<BackgroundTask> GetBackgroundTasks(this AppState state) =>
state.Tasks.Values.Where(t => t.Status is TaskStatus.Running or TaskStatus.Pending)
.Where(t => t.IsBackgrounded)
.ToList();
public static bool HasActiveMcpServers(this AppState state) =>
state.Mcp.Clients.Any(c => c.IsConnected);
public static int GetActiveSessionCount(this AppState state) =>
state.Tasks.Values.Count(t => t.Status == TaskStatus.Running);
public static string? GetActiveModel(this AppState state) =>
state.MainLoopModelForSession ?? state.MainLoopModel;
}
```

View File

@ -0,0 +1,83 @@
# 基础设施设计
> **所属项目**: free-code .NET 10 重写
> **文档类型**: 模块索引
> **对应源码**: `../../src/services/mcp/`, `../../src/services/lsp/`, `../../src/bridge/`, `../../src/tasks/`, `../../src/state/`
> **配套文档**: [总体概述](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md)
---
## 概述
基础设施层负责支撑整个 CLI 运行时的底层能力包括外部协议对接、IDE 通信、任务调度和全局状态管理。这一层不直接实现业务逻辑,而是为核心模块提供可靠的基础服务。
原始 TypeScript 实现分散在多个目录中,总计约 76 个文件。.NET 10 重写将其整理为五个职责清晰的子模块,每个子模块通过接口向上层暴露能力,内部实现细节完全封装。
---
## 子模块列表
### [MCP 协议集成](基础设施设计-MCP协议集成.md)
管理多个 MCP 服务器连接,实现 JSON-RPC 2.0 传输层Stdio、SSE、Streamable HTTP、WebSocket、进程内并将 MCP 工具适配为统一的 `ITool` 接口供工具系统使用。
- 原始源码: `../../src/services/mcp/`22 个文件)
- 核心类型: `IMcpClientManager``MCPServerConnection``McpClient``McpAuthService`
---
### [LSP 集成](基础设施设计-LSP集成.md)
管理语言服务器协议LSP子进程的生命周期提供跳转定义、查找引用、悬停提示、重命名、诊断等 9 种标准 LSP 操作,以及诊断基线对比能力。
- 原始源码: `../../src/services/lsp/`7 个文件)
- 核心类型: `ILspClientManager``LspServerInstance``LspDiagnosticRegistry`
---
### [IDE 桥接](基础设施设计-IDE桥接.md)
实现 claude.ai 远程控制能力,通过轮询 API 获取工作项、生成隔离的会话目录git worktree并管理会话生命周期。支持 VS Code、JetBrains 等 IDE 的 MCP over SSE/WebSocket 接入。
- 原始源码: `../../src/bridge/`32 个文件)
- 核心类型: `IBridgeService``BridgeConfig``SpawnMode``IBridgeApiClient`
---
### [后台任务管理](基础设施设计-后台任务管理.md)
基于 `Channel<T>` 的任务调度系统,支持 7 种任务类型Shell、本地代理、远程代理、进程内协作代理、工作流、MCP 监控、Dream 记忆合并),作为 `IHostedService` 随应用生命周期启停。
- 原始源码: `../../src/tasks/`10 个文件)
- 核心类型: `BackgroundTask``IBackgroundTaskManager``BackgroundTaskManager`
---
### [状态管理](基础设施设计-状态管理.md)
应用全局不可变状态存储,采用 Redux/Elm 风格的 updater 函数模式。`AppState` 是一个深层嵌套的不可变 record`AppStateStore` 通过加锁保证线程安全,并在每次更新后触发订阅通知。
- 原始源码: `../../src/state/`5 个文件)
- 核心类型: `AppState``IAppStateStore``AppStateStore``StateSelectors`
---
## 依赖关系
```
核心模块(工具系统、查询引擎)
↓ 使用
基础设施层
├── MCP 协议集成 ──→ 状态管理(更新 McpState
├── LSP 集成 ──→ 状态管理(无直接依赖,通过工具层间接访问)
├── IDE 桥接 ──→ 状态管理(更新桥接状态字段)
└── 后台任务管理 ──→ 状态管理(更新 Tasks 字典)
```
所有子模块都通过依赖注入接收 `IAppStateStore`,并在状态变更时调用 `Update()` 方法。UI 层订阅 `StateChanged` 事件实现响应式刷新。
---
## 原始代码映射
完整的 .NET 类型与原始 TypeScript 文件对应关系,参见 [reference/原始代码映射-基础设施.md](reference/原始代码映射-基础设施.md)。

View File

@ -0,0 +1,328 @@
# .NET 10 平台介绍
> **文档版本**: 1.0
> **日期**: 2026-04-05
> **所属部分**: 第一部分 — 参考文档
> **单一职责**: 说明 .NET 10 平台中与本项目直接相关的特性,不涉及具体映射细节
---
## 目录
- [1. AOT 编译与单文件发布](#1-aot-编译与单文件发布)
- [2. C# 13 语言特性](#2-c-13-语言特性)
- [3. System.Text.Json 与 AOT 友好序列化](#3-systemtextjson-与-aot-友好序列化)
- [4. Microsoft.Extensions.Hosting](#4-microsoftextensionshosting)
- [5. AssemblyLoadContext 插件隔离](#5-assemblyloadcontext-插件隔离)
- [6. System.Threading.Channels 异步任务管理](#6-systemthreadingchannels-异步任务管理)
- [7. 关键 NuGet 包清单](#7-关键-nuget-包清单)
---
## 1. AOT 编译与单文件发布
### 设计意图
原始项目通过 Bun 将所有 TypeScript 打包为单一可执行二进制,做到零依赖安装。.NET 10 的 Native AOTAhead-of-Time编译提供了对等的能力并在启动速度和内存占用上更有优势。
### PublishSingleFile
`PublishSingleFile` 将运行时和应用代码打包为一个文件,发布时无需 .NET 运行时预装。
```xml
<!-- 项目文件中的 AOT + 单文件配置 -->
<PropertyGroup>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<AotPublish>true</AotPublish>
<RuntimeIdentifiers>osx-arm64;osx-x64;linux-x64;linux-arm64;win-x64</RuntimeIdentifiers>
</PropertyGroup>
```
### Native AOT 的约束
AOT 编译会在发布时静态分析所有代码路径任何运行时反射都可能被裁剪trim。这意味着
- `System.Text.Json` 必须使用 Source Generator 模式,不能依赖运行时反射序列化
- 插件系统无法直接 AOT主程序集使用 AOT插件程序集在 `AssemblyLoadContext` 中通过 JIT 加载
- 需要为 `[DynamicallyAccessedMembers]` 标注所有通过反射访问的类型
### 启动性能对比
| 方式 | 冷启动时间(参考值) |
|------|-----------------|
| Bun 打包 JS | ~80ms |
| .NET 10 AOT | ~15-30ms |
| .NET 10 JIT无 AOT | ~150-300ms |
---
## 2. C# 13 语言特性
### 设计意图
TypeScript 的类型系统依赖接口、联合类型和 Zod 运行时校验。C# 13 提供了更强的编译时保证,用 record 类型替代不可变 DTO用模式匹配替代联合类型的分支处理。
### Record 类型
Record 类型天然不可变,自动生成 `Equals``GetHashCode``ToString` 和解构支持,适合表达状态快照和配置对象。
```csharp
// 不可变配置模型
public record AppSettings(
string Model,
string Theme,
bool VerboseMode,
IReadOnlyList<string> AllowedDirectories
);
// 带 required 修饰符的 init-only 属性
public record FileReadInput
{
public required string FilePath { get; init; }
public int? Offset { get; init; }
public int? Limit { get; init; }
}
```
### 模式匹配
C# 13 的模式匹配可以替代 TypeScript 中对联合类型的 `switch` + `instanceof` 处理。
```csharp
// 替代 TS 的 if (msg.type === 'tool_use') { ... }
var result = message switch
{
ToolUseMessage toolUse => HandleToolUse(toolUse),
AssistantMessage assistant => HandleAssistant(assistant),
ErrorMessage { Code: >= 500 } serverError => HandleServerError(serverError),
_ => HandleUnknown(message)
};
```
### required init 属性
`required` 关键字在编译时强制调用方必须初始化某个属性,比构造函数参数更灵活,比 Nullable 警告更明确。
```csharp
public class ToolExecutionContext
{
public required string SessionId { get; init; }
public required IPermissionEngine PermissionEngine { get; init; }
public CancellationToken CancellationToken { get; init; }
}
```
---
## 3. System.Text.Json 与 AOT 友好序列化
### 设计意图
原始项目直接使用 `JSON.parse`/`JSON.stringify`,无类型安全。`System.Text.Json` 提供高性能序列化,配合 Source Generator 完全绕过运行时反射AOT 环境下也可正常工作。
### Source Generator 模式
通过 `[JsonSerializable]` 特性,编译器在构建时生成所有序列化代码,不依赖运行时反射。
```csharp
// 声明需要序列化的类型
[JsonSerializable(typeof(ApiRequest))]
[JsonSerializable(typeof(ApiResponse))]
[JsonSerializable(typeof(McpToolCall))]
[JsonSerializable(typeof(List<ToolDefinition>))]
internal partial class AppJsonContext : JsonSerializerContext { }
// 使用时传入 context
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.ApiRequest);
var response = JsonSerializer.Deserialize(json, AppJsonContext.Default.ApiResponse);
```
### 流式 JSON 处理
MCP 和 API 响应都基于流式 JSON使用 `Utf8JsonReader` 可以零拷贝解析,不需要先将整个响应体读入内存。
```csharp
// SSE 流式解析示例
await foreach (var line in sseReader.ReadLinesAsync(ct))
{
if (!line.StartsWith("data: ")) continue;
var data = line["data: ".Length..];
if (data == "[DONE]") break;
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(data));
var chunk = JsonSerializer.Deserialize(ref reader, AppJsonContext.Default.StreamChunk);
yield return chunk;
}
```
---
## 4. Microsoft.Extensions.Hosting
### 设计意图
原始项目没有显式的 DI 容器,各模块通过模块级变量和闭包共享状态。.NET 的 `Microsoft.Extensions.Hosting` 提供了标准化的应用生命周期管理,包括 DI 注册、配置系统和 `BackgroundService`
### Host 构建
```csharp
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((ctx, cfg) =>
{
cfg.AddJsonFile("appsettings.json", optional: true);
cfg.AddEnvironmentVariables("FREECODE_");
cfg.AddCommandLine(args);
})
.ConfigureServices((ctx, services) =>
{
services.AddCoreServices()
.AddEngine()
.AddTools()
.AddCommands()
.AddApiProviders()
.AddMcp()
.AddLsp();
})
.ConfigureLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Warning); // 终端应用默认不打印 Info 日志
})
.Build();
```
### BackgroundService
`BackgroundService` 是 .NET 中运行后台循环任务的标准模式,替代原始项目中的自定义异步循环。
```csharp
public class TaskSchedulerService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var task in _taskChannel.Reader.ReadAllAsync(stoppingToken))
{
_ = Task.Run(() => ExecuteTaskAsync(task, stoppingToken), stoppingToken);
}
}
}
```
---
## 5. AssemblyLoadContext 插件隔离
### 设计意图
原始项目使用 `动态 import()` 加载插件JavaScript 的模块系统天然支持按需加载。.NET 中通过 `AssemblyLoadContext` 实现对等的运行时程序集隔离并支持卸载collectible context
### 基本用法
```csharp
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath)
: base(isCollectible: true) // 可卸载
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 优先从插件目录解析,避免与主程序集冲突
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
return assemblyPath != null ? LoadFromAssemblyPath(assemblyPath) : null;
}
}
```
### 卸载流程
```csharp
// 加载插件
var context = new PluginLoadContext(pluginPath);
var assembly = context.LoadFromAssemblyPath(pluginPath);
var plugin = (IPlugin)Activator.CreateInstance(assembly.GetType("MyPlugin")!)!;
// 卸载插件(释放所有强引用后 GC 自动回收)
context.Unload();
```
### AOT 注意事项
主程序集使用 AOT 编译,但插件程序集在运行时通过 `AssemblyLoadContext` 以 JIT 模式加载。这是混合部署模式:主入口 AOT 启动快、内存小,插件保持 JIT 的灵活性。
---
## 6. System.Threading.Channels 异步任务管理
### 设计意图
原始项目的后台任务通过自定义队列和 Promise 链管理,存在竞态风险。`System.Threading.Channels` 是 .NET 内建的高性能生产者-消费者通道线程安全背压backpressure支持完善。
### Channel 基本模式
```csharp
// 创建无界通道
var channel = Channel.CreateUnbounded<BackgroundTask>(new UnboundedChannelOptions
{
SingleWriter = false,
SingleReader = true // 单消费者简化并发模型
});
// 生产者(任意线程)
await channel.Writer.WriteAsync(new LocalShellTask { Command = "npm test" }, ct);
// 消费者BackgroundService 中)
await foreach (var task in channel.Reader.ReadAllAsync(ct))
{
await ProcessTaskAsync(task, ct);
}
```
### 有界通道与背压
```csharp
// 有界通道:满了就等待(背压)
var bounded = Channel.CreateBounded<ApiRequest>(new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait
});
```
---
## 7. 关键 NuGet 包清单
以下是本项目直接依赖的 NuGet 包,版本号为设计时目标版本。
| 包名 | 版本 | 用途 |
|------|------|------|
| `Terminal.Gui` | 2.* | 终端 TUI 框架,替代 React+Ink |
| `Spectre.Console` | 0.* | 富文本输出:表格、进度条、面板 |
| `System.CommandLine` | 2.* | CLI 参数解析,替代 Commander.js |
| `FluentValidation` | 12.* | POCO 对象校验,替代 Zod |
| `JsonSchema.Net` | 7.* | JSON Schema 生成与校验 |
| `Refit` | 8.* | 强类型 HTTP 客户端 |
| `Polly` | 8.* | 重试、熔断、超时策略 |
| `StreamJsonRpc` | 2.* | JSON-RPC 2.0 实现,用于 MCP 协议 |
| `OmniSharp.Extensions.LanguageServer.Client` | 0.* | LSP 客户端 |
| `Grpc.Net.Client` | 2.* | gRPC 客户端(部分 API 提供商) |
| `Microsoft.Identity.Client` (MSAL) | 4.* | OAuth/OIDC 认证 |
| `IdentityModel.OidcClient` | 6.* | OIDC 客户端流程 |
| `AWSSDK.BedrockRuntime` | 4.* | AWS Bedrock 流式 API |
| `Google.Apis.Auth` | 1.* | Google Cloud 认证Vertex AI |
| `Microsoft.AspNetCore.SignalR.Client` | 10.* | WebSocket 高层抽象 |
| `Markdig` | 0.* | Markdown 渲染 |
| `DiffPlex` | 1.* | diff 计算与展示 |
| `SixLabors.ImageSharp` | 3.* | 图片处理 |
| `PdfPig` | 0.* | PDF 文本提取 |
| `YamlDotNet` | 16.* | YAML 解析 |
| `Microsoft.Extensions.Hosting` | 10.* | DI + 生命周期管理 |
| `System.Threading.Channels` | 10.* | 生产者-消费者通道 |
| `xunit` | 2.* | 单元测试框架 |
| `NSubstitute` | 5.* | Mock 框架 |
| `FluentAssertions` | 7.* | 断言库 |

View File

@ -0,0 +1,346 @@
# MCP SDK 自研实现方案
> 关联文档:[总体概述](../总体概述与技术选型.md) | [MCP协议集成](../../基础设施设计/基础设施设计-MCP协议集成.md)
---
## 1. 为什么自研
社区目前没有成熟的 .NET MCP SDK。检索 NuGet.org 和相关 GitHub 仓库后,现状如下:
- 微软官方尚未发布 `Microsoft.Extensions.Mcp` 正式包(截至 2026 年初仍在 preview 阶段API 不稳定)
- 社区的几个实验性实现覆盖场景有限,停留在 MCP 协议的早期版本,不支持 Streamable HTTP Transport
- 没有任何包支持原生 AOT 编译,大量依赖运行时反射
自研的核心收益:
1. **协议版本精确对齐**,与 TypeScript SDK 的行为保持一致,减少互操作问题
2. **传输层可插拔**,在测试环境使用 InProcessTransport生产环境使用 StdioTransport 或 SseTransport
3. **AOT 友好**,所有序列化走 `JsonSerializerContext`,无运行时反射
4. **依赖最小化**,不引入不受控的第三方包,安全审查更简单
---
## 2. JSON-RPC 2.0 协议基础
MCP 基于 JSON-RPC 2.0 协议。三种消息类型构成所有交互:
**请求 (Request)**
客户端发起,期待服务端响应。必须携带 `id`,方法名决定语义:
```json
{
"jsonrpc": "2.0",
"id": "req-001",
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": { "path": "/tmp/foo.txt" }
}
}
```
**响应 (Response)**
服务端对请求的回复,`id` 必须与请求一致。成功用 `result`,失败用 `error`
```json
{
"jsonrpc": "2.0",
"id": "req-001",
"result": {
"content": [{ "type": "text", "text": "file contents here" }]
}
}
```
**通知 (Notification)**
单向消息,不携带 `id`,无需响应:
```json
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
```
对应的 C# 模型:
```csharp
public record JsonRpcRequest(
string JsonRpc,
string Id,
string Method,
JsonElement? Params
);
public record JsonRpcResponse(
string JsonRpc,
string Id,
JsonElement? Result,
JsonRpcError? Error
);
public record JsonRpcNotification(
string JsonRpc,
string Method,
JsonElement? Params
);
public record JsonRpcError(int Code, string Message, JsonElement? Data);
```
---
## 3. 传输层实现
传输层负责消息的发送和接收,与协议逻辑解耦。所有传输层实现同一接口:
```csharp
public interface ITransport : IAsyncDisposable
{
Task<JsonRpcMessage> ReadMessageAsync(CancellationToken ct);
Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct);
Task ConnectAsync(CancellationToken ct);
Task CloseAsync(CancellationToken ct);
}
```
### StdioTransport
通过子进程的 stdin/stdout 通信,是 MCP 最常见的本地工具接入方式:
```csharp
public sealed class StdioTransport : ITransport
{
private readonly Process _process;
private readonly Channel<JsonRpcMessage> _inbound =
Channel.CreateUnbounded<JsonRpcMessage>();
public async Task ConnectAsync(CancellationToken ct)
{
_process.Start();
// 启动后台读取循环,将 stdout 行解析为 JsonRpcMessage 写入 Channel
_ = Task.Run(() => ReadLoopAsync(ct), ct);
}
public async Task<JsonRpcMessage> ReadMessageAsync(CancellationToken ct)
=> await _inbound.Reader.ReadAsync(ct);
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct)
{
var json = JsonSerializer.Serialize(message, FreeCodeJsonContext.Default.JsonRpcMessage);
await _process.StandardInput.WriteLineAsync(json.AsMemory(), ct);
await _process.StandardInput.FlushAsync(ct);
}
}
```
每行 JSON 对应一条消息,严格遵循 MCP 规范的 newline-delimited JSON 格式。
### SseTransport
服务端通过 HTTP Server-Sent Events 推送消息,客户端通过 HTTP POST 发送:
```csharp
public sealed class SseTransport : ITransport
{
private readonly HttpClient _http;
private readonly Uri _sseEndpoint;
private readonly Uri _postEndpoint;
public async Task ConnectAsync(CancellationToken ct)
{
var response = await _http.GetAsync(
_sseEndpoint,
HttpCompletionOption.ResponseHeadersRead,
ct);
// 以流方式持续读取 SSE 事件行
_ = Task.Run(() => ReadSseLoopAsync(response, ct), ct);
}
}
```
### StreamableHttpTransport
MCP 协议 2025-03-26 版本引入的新传输方式,支持单次 HTTP 请求内的双向流式通信:
```csharp
public sealed class StreamableHttpTransport : ITransport
{
// 使用 HttpClient 发送请求,响应体作为 NDJSON 流持续读取
// 请求体也可以是 NDJSON 流(客户端到服务端)
// 通过 Content-Type: application/jsonl 区分
}
```
### WebSocketTransport
适用于需要长连接和低延迟的场景:
```csharp
public sealed class WebSocketTransport : ITransport
{
private readonly ClientWebSocket _ws = new();
public async Task ConnectAsync(CancellationToken ct)
{
await _ws.ConnectAsync(_uri, ct);
_ = Task.Run(() => ReceiveLoopAsync(ct), ct);
}
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken ct)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(
message, FreeCodeJsonContext.Default.JsonRpcMessage);
await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
}
```
### InProcessTransport
测试和嵌入场景专用,通过一对 `Channel<JsonRpcMessage>` 模拟双向通信,无网络开销:
```csharp
public sealed class InProcessTransport : ITransport
{
private readonly Channel<JsonRpcMessage> _clientToServer;
private readonly Channel<JsonRpcMessage> _serverToClient;
// 客户端和服务端各持有一端的 Reader/Writer
// 完全同步deterministic适合单元测试
}
```
---
## 4. 客户端生命周期
MCP 客户端从连接到关闭经历五个阶段:
```
connect → initialize → initialized → [tool calls / resource reads] → shutdown
```
**阶段一connect**
传输层建立物理连接进程启动、HTTP 连接、WebSocket 握手)。
**阶段二initialize**
客户端发送 `initialize` 请求,声明协议版本和能力:
```json
{
"jsonrpc": "2.0",
"id": "init-1",
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"clientInfo": { "name": "FreeCode", "version": "0.1.0" },
"capabilities": { "roots": { "listChanged": true } }
}
}
```
**阶段三initialized**
服务端响应 `initialize`,返回其支持的能力和协议版本后,客户端发送 `notifications/initialized` 通知,握手完成。
**阶段四:工具调用**
握手完成后,客户端可以发起任意 `tools/call``resources/read``prompts/get` 等请求。`McpClient` 内部维护一个 pending request 字典,通过 `id` 匹配请求和响应:
```csharp
public async Task<CallToolResult> CallToolAsync(
string toolName,
IReadOnlyDictionary<string, JsonElement> arguments,
CancellationToken ct)
{
var id = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<JsonRpcResponse>();
_pending[id] = tcs;
await _transport.SendMessageAsync(new JsonRpcRequest(
"2.0", id, "tools/call",
BuildParams(toolName, arguments)), ct);
var response = await tcs.Task.WaitAsync(ct);
return ParseCallToolResult(response);
}
```
**阶段五shutdown**
客户端发送 `shutdown` 请求,等待服务端 `null` 响应后发送 `exit` 通知,最后关闭传输层。
---
## 5. MCP 工具适配为 ITool 接口
McpToolWrapper 将 MCP 服务端暴露的工具包装为 FreeCode 的 `ITool` 接口,使得 MCP 工具和本地工具在 Engine 层完全透明:
```csharp
public sealed class McpToolWrapper : ITool
{
private readonly McpClient _client;
private readonly McpToolDefinition _definition;
public string Name => $"mcp_{_client.ServerId}_{_definition.Name}";
public string Description => _definition.Description ?? string.Empty;
public JsonElement InputSchema => _definition.InputSchema;
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
CancellationToken ct)
{
var args = ParseArguments(input);
var result = await _client.CallToolAsync(_definition.Name, args, ct);
return new ToolResult(
Success: !result.IsError,
Content: result.Content.Select(MapContent).ToList());
}
}
```
McpManager 在启动时连接所有配置的 MCP 服务器,拉取工具列表,为每个工具创建 McpToolWrapper 并注册到 ToolRegistry。Engine 层通过 ToolRegistry 统一调度,无需感知来源。
---
## 6. 与 TypeScript SDK 对比
| 特性 | @modelcontextprotocol/sdk (TS) | FreeCode.Mcp (C# 自研) |
|------|-------------------------------|----------------------|
| 传输层 | Stdio、SSE、StreamableHttp | Stdio、SSE、StreamableHttp、WebSocket、InProcess |
| 序列化 | Zod 运行时验证 | System.Text.Json + Source Generator |
| AOT 支持 | 不适用 | 完全支持 |
| 异步模型 | Promise / async-await | ValueTask / Channel\<T\> |
| 协议版本 | 跟随官方 | 对齐 2025-03-26 |
| 测试支持 | Jest mock | InProcessTransport |
| 服务端实现 | 完整 | 计划中v0.3 |
TypeScript SDK 使用 Zod schema 自动验证所有传入和传出消息。C# 自研版本通过 `JsonSerializerContext` 反序列化时的类型约束和 FluentValidation 检查实现等效的安全性,且无运行时反射开销。
---
## 7. 关键设计决策
**Channel\<T\> 用于异步消息传递**
传输层的读取循环与业务调用层之间通过 `Channel<JsonRpcMessage>` 解耦。读取循环是一个长期运行的后台任务,不阻塞调用线程。`Channel<T>` 支持背压(有界通道)和取消,比 `BlockingCollection<T>` 更适合全异步场景。
**不可变 Record 表示协议状态**
所有 JSON-RPC 消息和 MCP 协议模型都定义为 C# `record`,天然不可变。消息一旦构建就不会被修改,消除了多线程读写竞争的可能,也使测试断言更简单。
**id 用字符串而不是整数**
JSON-RPC 2.0 允许 id 为字符串或整数。FreeCode.Mcp 统一使用 `Guid.NewGuid().ToString("N")` 生成字符串 id避免高并发下的整数碰撞也方便日志追踪。
**传输层不感知协议**
`ITransport` 只负责原始 `JsonRpcMessage` 的收发,不理解 `initialize``tools/call` 等语义。协议逻辑完全在 `McpClient``McpServer` 中,传输层可以随时替换,不影响上层逻辑。这个边界在集成测试中尤其有价值:用 InProcessTransport 替换 StdioTransport测试速度从秒级降到毫秒级。

View File

@ -0,0 +1,151 @@
# 技术栈映射说明
> 关联文档:[总体概述](../总体概述与技术选型.md)
本文档说明 FreeCode .NET 版在技术选型上的完整映射逻辑,以及每个替代方案的选择依据。
---
## 1. 映射原则
在将 TypeScript/Bun 技术栈迁移到 .NET 10 的过程中,我们遵循三条核心原则,优先级由高到低:
**成熟库优先**
选用社区已经过生产环境验证的库,而不是最新但尚不稳定的方案。工具越成熟,坑越少,维护成本越低。
**官方优先**
微软官方或 .NET 基金会维护的包拥有更长的生命周期承诺和更一致的 API 风格。第三方包在官方方案缺失或明显不足时才纳入考虑。
**AOT 兼容优先**
FreeCode 最终需要编译为原生 AOT 二进制,以实现秒级启动和最小依赖。凡是依赖运行时反射、动态代码生成的库都要审慎对待,并在必要时通过 Source Generator 绕过。
---
## 2. 技术栈映射总表
| 层级 | 原始 (TS/Bun) | .NET 10 替代 | 版本 | 理由 |
|------|--------------|-------------|------|------|
| 运行时 | Bun 1.x | .NET 10 AOT | 10.0 | 原生 AOT 编译,秒级启动,单文件分发,内存占用更低 |
| 语言 | TypeScript 6 ESM | C# 13 | 13.0 | 静态类型、模式匹配、records、primary constructors表达力与 TS 相当 |
| 终端 UI | React 19 + Ink 6 | Terminal.Gui v2 | 2.x | 唯一成熟的跨平台 .NET TUI 框架响应式布局AOT 友好 |
| 富文本输出 | Ink built-in | Spectre.Console | 0.49.x | 表格、进度条、标记语言API 优雅零反射AOT 完全兼容 |
| CLI 解析 | Commander.js | System.CommandLine v2 | 2.0.0-beta | 微软官方,支持 AOT子命令/选项/参数体系完整 |
| Schema 验证 | Zod v4 | FluentValidation + JsonSchema.Net | 11.x / 7.x | FluentValidation 负责业务规则验证JsonSchema.Net 负责 JSON Schema 生成与校验 |
| 代码搜索 | ripgrep (内置) | ripgrep via Process | — | ripgrep 本身是独立二进制,通过 `System.Diagnostics.Process` 调用,行为完全一致 |
| LSP 客户端 | vscode-languageserver-protocol | OmniSharp.Extensions.LanguageServer | 0.19.x | OmniSharp 团队出品,协议覆盖完整,支持双向消息和自定义扩展 |
| MCP 协议 | @modelcontextprotocol/sdk | 自研 MCP SDK | — | 社区尚无成熟 .NET MCP SDK自研可精确控制传输层和协议版本详见 [MCP SDK 自研实现方案](mcp-sdk-implement.md) |
| HTTP 客户端 | Bun fetch | HttpClient + Refit | — / 7.x | HttpClient 是 .NET 标准组件Refit 提供声明式接口减少样板代码,支持 AOT |
| OAuth / OIDC | 自研 | MSAL.NET + IdentityModel.OidcClient | 4.x / 5.x | MSAL.NET 覆盖 Azure/GitHub/GoogleOidcClient 处理标准 OIDC 设备码流 |
| WebSocket | ws | System.Net.WebSockets + SignalR | — / 8.x | 标准库 WebSockets 用于原始帧处理SignalR 用于需要自动重连的场景 |
| 子进程 | Bun.spawn | System.Diagnostics.Process | — | BCL 标准组件,行为跨平台一致,支持异步读写流 |
| JSON 序列化 | JSON.parse/stringify | System.Text.Json | — | BCL 标准组件Source Generator 模式下零反射AOT 完全兼容 |
| 后台任务 | 自研 TaskQueue | BackgroundService + Channel\<T\> | — | BackgroundService 是 .NET 托管服务标准抽象Channel\<T\> 提供高性能无锁消息传递 |
| 插件系统 | dynamic import() | AssemblyLoadContext | — | .NET 官方插件隔离机制,支持热加载和独立卸载 |
| 依赖注入 | 无 | Microsoft.Extensions.DependencyInjection | 10.0 | .NET 官方 DI 容器轻量、AOT 友好,通过 Source Generator 消除反射 |
| 日志 | console.log | Microsoft.Extensions.Logging | 10.0 | 官方日志抽象支持结构化日志后端可插拔Console/File/OTLP |
---
## 3. NuGet 包完整清单
```xml
<!-- FreeCode.csproj 及子项目汇总包引用 -->
<!-- 终端 UI -->
<PackageReference Include="Terminal.Gui" Version="2.*" />
<PackageReference Include="Spectre.Console" Version="0.49.*" />
<!-- CLI 解析 -->
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.*" />
<!-- 验证 -->
<PackageReference Include="FluentValidation" Version="11.*" />
<PackageReference Include="JsonSchema.Net" Version="7.*" />
<!-- HTTP / REST -->
<PackageReference Include="Refit" Version="7.*" />
<PackageReference Include="Refit.HttpClientFactory" Version="7.*" />
<!-- OAuth / OIDC -->
<PackageReference Include="Microsoft.Identity.Client" Version="4.*" />
<PackageReference Include="IdentityModel.OidcClient" Version="5.*" />
<!-- LSP -->
<PackageReference Include="OmniSharp.Extensions.LanguageServer.Client" Version="0.19.*" />
<PackageReference Include="OmniSharp.Extensions.LanguageProtocol" Version="0.19.*" />
<!-- JSON -->
<!-- System.Text.Json 随 .NET 10 SDK 内置,无需额外引用 -->
<!-- DI / 日志 / 托管 -->
<!-- Microsoft.Extensions.* 随 .NET 10 SDK 内置 -->
<!-- 后台任务 -->
<!-- System.Threading.Channels 随 .NET 10 SDK 内置 -->
<!-- AST 搜索ast-grep 绑定) -->
<PackageReference Include="AstGrep.Bindings" Version="0.30.*" />
<!-- 序列化辅助 -->
<PackageReference Include="System.Text.Json.SourceGeneration" Version="10.0.*" />
<!-- 测试 -->
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
```
---
## 4. AOT 兼容性说明
原生 AOT 编译是本项目的核心约束,它要求在编译期完成所有类型分析,运行时不允许动态反射、动态代码生成和未知类型的延迟绑定。
**Source Generator 策略**
System.Text.Json 的 AOT 支持通过 `JsonSerializerContext` 实现。每个需要序列化的模型都注册到一个 partial context 类,编译器在构建期生成序列化代码,完全绕开运行时反射:
```csharp
[JsonSerializable(typeof(ChatRequest))]
[JsonSerializable(typeof(ChatResponse))]
internal partial class FreeCodeJsonContext : JsonSerializerContext { }
```
`System.CommandLine` v2 的 AOT 支持已在 beta 阶段引入,通过 Source Generator 生成命令绑定代码,替代早期版本的反射绑定。
Microsoft.Extensions.DependencyInjection 从 .NET 8 起提供 `ServiceProviderOptions.ValidateScopes` 的 AOT 路径,通过 `[ServiceDescriptor]` 属性消除反射注册扫描。
**裁剪 (Trimming) 配置**
```xml
<PublishTrimmed>true</PublishTrimmed>
<TrimmerRootDescriptor>TrimmerRoots.xml</TrimmerRootDescriptor>
```
Terminal.Gui v2 和 Spectre.Console 均已标注 `[RequiresUnreferencedCode]` 警告可抑制区域,实际裁剪测试证明两者在启用 `TrimmerRootDescriptor` 白名单后正常工作。
**不兼容项处理**
AssemblyLoadContext 用于插件加载,插件程序集本身不参与 AOT 编译,以动态加载的 JIT 模式运行。主程序 AOT 编译不受影响,两者通过接口合约隔离。
---
## 5. 关键选型设计说明
**Terminal.Gui v2 vs. 其他 TUI 方案**
Spectre.Console 的 Live 组件可以渲染动态布局但它本质上是顺序输出不支持真正的交互式控件文本框、列表框、焦点切换。FreeCode 的 REPL 界面需要多面板、快捷键绑定和滚动视图Terminal.Gui v2 是目前唯一满足条件的成熟方案。
**FluentValidation + JsonSchema.Net vs. 单一方案**
Zod 同时承担运行时验证和 Schema 生成两个职责。.NET 生态里没有一个库同时做好两件事因此分开处理FluentValidation 的 Fluent API 在业务规则验证上表达力强JsonSchema.Net 处理 JSON Schema 的生成和校验(主要用于工具参数描述)。
**Refit vs. 纯 HttpClient**
Refit 将 HTTP 接口声明为 C# interface通过 Source Generatorv7+生成实现代码不依赖运行时代理AOT 兼容。对于结构化的 LLM API 调用来说,减少样板代码的收益明显。
**Channel\<T\> vs. BlockingCollection/Queue**
`Channel<T>` 是 .NET 的高性能异步消息原语,支持有界/无界背压,完全基于 `ValueTask` 避免内存分配,与 `BackgroundService` 的异步生命周期天然契合。`BlockingCollection` 是同步阻塞模型,在异步场景下会占用线程。

View File

@ -0,0 +1,199 @@
# 解决方案结构说明
> 关联文档:[总体概述](../总体概述与技术选型.md)
本文档描述 FreeCode .NET 解决方案的项目划分方式、各项目职责和相互依赖关系,帮助开发者快速定位代码归属和贡献边界。
---
## 1. FreeCode.sln 概览
解决方案包含 **17 个源码项目****9 个测试项目**,合计 26 个项目。整体采用垂直切分策略:每个项目对应一个关注点,通过接口而非直接引用来解耦。
```
FreeCode.sln
├── 源码项目 (17)
│ ├── FreeCode # 主可执行入口
│ ├── FreeCode.Core # 核心接口与模型
│ ├── FreeCode.Engine # 查询引擎
│ ├── FreeCode.Tools # 工具实现
│ ├── FreeCode.Commands # 命令实现
│ ├── FreeCode.ApiProviders # LLM API 适配
│ ├── FreeCode.Mcp # MCP 协议
│ ├── FreeCode.Lsp # LSP 集成
│ ├── FreeCode.Bridge # IDE 桥接
│ ├── FreeCode.Services # 业务服务
│ ├── FreeCode.Tasks # 后台任务
│ ├── FreeCode.Skills # 技能系统
│ ├── FreeCode.Plugins # 插件系统
│ ├── FreeCode.Features # 特性开关
│ ├── FreeCode.State # 状态管理
│ └── FreeCode.TerminalUI # 终端 UI 组件
└── 测试项目 (9)
├── FreeCode.Core.Tests
├── FreeCode.Engine.Tests
├── FreeCode.Tools.Tests
├── FreeCode.Commands.Tests
├── FreeCode.ApiProviders.Tests
├── FreeCode.Mcp.Tests
├── FreeCode.Services.Tests
├── FreeCode.Tasks.Tests
└── FreeCode.Integration.Tests
```
---
## 2. 各项目职责说明
| 项目名 | 职责 | 关键类型 | 依赖项目 |
|--------|------|----------|----------|
| **FreeCode** | 主可执行 Console App入口点、DI 容器组装、命令行解析 | `Program`, `RootCommand`, `Startup` | 全部项目 |
| **FreeCode.Core** | 核心接口与模型25+ 接口、20+ 模型,无外部依赖,是整个解决方案的契约层 | `ITool`, `ICommand`, `IApiProvider`, `ChatMessage`, `ToolResult`, `AppConfig` | 无 |
| **FreeCode.Engine** | QueryEngine 协调消息流与工具调用PromptBuilder 构建系统提示词 | `QueryEngine`, `PromptBuilder`, `ToolDispatcher`, `ConversationHistory` | Core |
| **FreeCode.Tools** | 50+ 工具实现按子目录分组FileSystem、Shell、Agent、Web、Mcp、UserInteraction、PlanMode、Swarms、Worktree | `BashTool`, `ReadFileTool`, `GlobTool`, `GrepTool`, `AgentTool`, `BrowserTool`, `McpTool`, `PlanModeTool`, `WorktreeTool` | Core, Services, Mcp |
| **FreeCode.Commands** | 70+ 斜杠命令实现,每个命令对应一个类 | `ClearCommand`, `CompactCommand`, `ReviewWorkCommand`, `InitDeepCommand`, `RalphLoopCommand` | Core, Engine, State, Skills |
| **FreeCode.ApiProviders** | 5 个 LLM 提供商适配Anthropic、OpenAI、Gemini、GitHub Copilot、Bedrock | `ClaudeProvider`, `OpenAiProvider`, `GeminiProvider`, `CopilotProvider`, `BedrockProvider` | Core |
| **FreeCode.Mcp** | MCP 协议客户端与服务端,传输层实现,工具适配器 | `McpClient`, `McpServer`, `StdioTransport`, `SseTransport`, `McpToolWrapper` | Core |
| **FreeCode.Lsp** | LSP 客户端集成,支持 goto-definition、find-references、diagnostics、rename | `LspClient`, `LspDiagnosticsProvider`, `LspWorkspaceManager` | Core |
| **FreeCode.Bridge** | IDE 桥接层,与 VS Code / JetBrains 等 IDE 扩展通信 | `BridgeServer`, `BridgeMessage`, `IdeEventHandler` | Core, Services |
| **FreeCode.Services** | 业务服务层:文件系统抽象、配置管理、权限管理、搜索服务 | `FileService`, `ConfigService`, `PermissionService`, `RipgrepService`, `AstGrepService` | Core |
| **FreeCode.Tasks** | 后台任务管理任务队列、任务状态跟踪、MCP 监控任务、Workflow 任务 | `BackgroundTaskManager`, `TaskQueue`, `MonitorMcpTask`, `LocalWorkflowTask` | Core, Services |
| **FreeCode.Skills** | 技能系统:技能加载、索引、搜索和执行 | `SkillLoader`, `SkillIndex`, `SkillSearchService`, `SkillExecutor` | Core, Services |
| **FreeCode.Plugins** | 插件系统:通过 AssemblyLoadContext 动态加载插件,插件发现和生命周期管理 | `PluginLoader`, `PluginHost`, `PluginRegistry` | Core |
| **FreeCode.Features** | 特性开关系统,控制实验性功能的启用状态,支持编译期和运行期两种模式 | `FeatureFlags`, `FeatureRegistry`, `IFeatureFlag` | Core |
| **FreeCode.State** | 应用状态管理,线程安全的状态读写,状态订阅 | `AppStateStore`, `ConversationState`, `SessionState` | Core |
| **FreeCode.TerminalUI** | Terminal.Gui v2 组件封装REPL 界面、输出渲染、进度显示 | `ReplView`, `OutputPane`, `StatusBar`, `ThemeManager` | Core, State |
---
## 3. 项目依赖关系图
```
┌─────────────────┐
│ FreeCode.Core │
│ (接口 + 模型) │
└────────┬────────┘
│ 被所有项目引用
┌─────────────────────┼──────────────────────────┐
│ │ │
┌──────▼──────┐ ┌─────────▼─────────┐ ┌──────────▼────────┐
│FreeCode. │ │FreeCode.ApiProviders│ │FreeCode.Services │
│Features │ │(LLM 适配层) │ │(文件/搜索/配置) │
└──────┬──────┘ └─────────┬─────────┘ └──────────┬────────┘
│ │ │
┌──────▼──────┐ ┌─────────▼─────────┐ ┌──────────▼────────┐
│FreeCode. │ │ FreeCode.Engine │ │ FreeCode.Mcp │
│State │ │ (QueryEngine) │ │ (MCP 协议) │
└──────┬──────┘ └─────────┬─────────┘ └──────────┬────────┘
│ │ │
┌──────▼─────────────────────▼──────────────────────────▼────────┐
│ FreeCode.Tools │
│ (50+ 工具, 依赖 Services + Mcp) │
└──────────────────────────┬─────────────────────────────────────┘
┌────────────────────┼────────────────────┐
│ │ │
┌──────▼──────┐ ┌────────▼───────┐ ┌───────▼────────┐
│FreeCode. │ │FreeCode.Skills │ │FreeCode.Tasks │
│Commands │ │(技能系统) │ │(后台任务) │
└──────┬──────┘ └────────┬───────┘ └───────┬────────┘
│ │ │
└────────────────────┼────────────────────┘
┌──────────▼─────────┐
│ FreeCode.TerminalUI│
│ (Terminal.Gui 组件)│
└──────────┬─────────┘
┌───────────────▼────────────────┐
│ FreeCode (主入口) │
│ DI 组装 + CLI + 程序生命周期 │
└────────────────────────────────┘
独立模块 (通过接口与主流程交互):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│FreeCode.Lsp │ │FreeCode.Bridge│ │FreeCode.Plugins│
│(LSP 客户端) │ │(IDE 桥接) │ │(动态插件) │
└──────────────┘ └──────────────┘ └──────────────┘
```
---
## 4. Directory.Build.props 共享配置
所有项目共享一个根级别的 `Directory.Build.props`,统一配置编译选项,避免在每个 `.csproj` 中重复:
```xml
<Project>
<PropertyGroup>
<!-- 语言版本 -->
<LangVersion>13.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- 目标框架 -->
<TargetFramework>net10.0</TargetFramework>
<!-- AOT 编译 (仅主项目覆盖为 true) -->
<PublishAot>false</PublishAot>
<PublishTrimmed>false</PublishTrimmed>
<IsAotCompatible>true</IsAotCompatible>
<!-- 警告即错误 -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<NoWarn>CS1591</NoWarn>
<!-- 分析器 -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<!-- 版本管理 -->
<Version>0.1.0</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<!-- 测试项目特定设置 -->
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
<IsTestProject>true</IsTestProject>
<IsAotCompatible>false</IsAotCompatible>
</PropertyGroup>
</Project>
```
主项目 `FreeCode.csproj` 在此基础上覆盖 AOT 相关设置:
```xml
<PropertyGroup>
<OutputType>Exe</OutputType>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
```
---
## 5. 测试项目结构
9 个测试项目覆盖核心业务逻辑,遵循一对一映射原则(一个源码项目对应一个测试项目),加一个集成测试项目。
| 测试项目 | 对应源码项目 | 测试重点 |
|----------|-------------|----------|
| FreeCode.Core.Tests | FreeCode.Core | 模型验证、接口约定、序列化正确性 |
| FreeCode.Engine.Tests | FreeCode.Engine | QueryEngine 消息流、工具调度逻辑、PromptBuilder 输出 |
| FreeCode.Tools.Tests | FreeCode.Tools | 各工具的单元行为,沙箱文件系统,进程调用 mock |
| FreeCode.Commands.Tests | FreeCode.Commands | 命令解析、执行逻辑、输出格式 |
| FreeCode.ApiProviders.Tests | FreeCode.ApiProviders | 请求构建、响应解析、错误处理、流式解析 |
| FreeCode.Mcp.Tests | FreeCode.Mcp | JSON-RPC 消息编解码、传输层、客户端生命周期 |
| FreeCode.Services.Tests | FreeCode.Services | 文件服务、配置加载、权限检查、搜索结果解析 |
| FreeCode.Tasks.Tests | FreeCode.Tasks | 任务队列背压、取消、状态转换 |
| FreeCode.Integration.Tests | 全部项目 | 端到端命令执行,真实 API可选通过环境变量控制 |
测试框架统一使用 **xUnit 2.x** + **FluentAssertions 6.x** + **Moq 4.x**。集成测试通过 `[Trait("Category", "Integration")]` 标注,默认 CI 跳过,本地按需运行:
```bash
dotnet test --filter "Category!=Integration"
dotnet test --filter "Category=Integration" # 需要设置 ANTHROPIC_API_KEY
```

View File

@ -0,0 +1,252 @@
# 总体概述与技术选型
> **文档版本**: 1.0
> **日期**: 2026-04-05
> **所属部分**: 第一部分
> **原始设计来源**: `DESIGN-NET10.md` 第一部分(第 1-4 节及附录)
---
## 目录
- [1. 项目概述](#1-项目概述)
- [2. 技术栈映射](#2-技术栈映射)
- [3. 顶层架构设计](#3-顶层架构设计)
- [4. 解决方案结构](#4-解决方案结构)
- [5. 关键设计决策](#5-关键设计决策)
- [模块覆盖对照表](#模块覆盖对照表)
- [参考文档](#参考文档)
---
## 1. 项目概述
free-code 是 Anthropic Claude Code CLI 的社区 fork一个终端原生 AI 编码代理。本次重写目标是将整个项目从 TypeScript/Bun/React+Ink 技术栈完整迁移至 .NET 10同时保持 100% 功能对等。
**原始版本规模**: v2.1.87,包含 200+ 个 TS/TSX 源文件、50+ 个 Agent 工具、70+ 个 Slash 命令、5 个 LLM 提供商接入。
### 核心功能清单
- **交互式 REPL**: 终端 AI 对话界面,流式响应、工具调用可视化、权限审批
- **50+ Agent 工具**: 文件读写编辑、Bash 执行、代码搜索、Web 访问、子代理生成
- **70+ Slash 命令**: 配置、会话管理、OAuth、插件、主题等
- **5 个 LLM 提供商**: Anthropic、OpenAI Codex、AWS Bedrock、Google Vertex AI、Anthropic Foundry
- **MCP/LSP 协议**: 外部工具集成 + 语言服务器
- **IDE 桥接**: VS Code/JetBrains 远程会话控制
- **技能/插件系统**: 可扩展 prompt 模板 + 功能包
- **后台任务、会话记忆、语音输入、远程会话、同伴系统**
### 重写目标
| 目标 | 描述 |
|------|------|
| 功能对等 | 100% 覆盖所有功能 |
| 性能提升 | .NET 10 AOT 编译,启动速度与内存双优化 |
| 类型安全 | C# 13 强类型替代 TypeScript 运行时校验 |
| 原生集成 | 更深度的 OS 集成(进程、文件系统、沙箱) |
| 可维护性 | 清晰模块边界、DI、接口驱动设计 |
> .NET 10 平台特性详见: [./reference/.NET-10-平台介绍.md](reference/.NET-10-平台介绍.md)
---
## 2. 技术栈映射
将 TypeScript 生态的每个技术点一一对应至 .NET 10 生态选型原则是成熟度优先、官方方案优先、AOT 兼容性优先。
| 层级 | 原始 (TS/Bun) | .NET 10 替代 | 理由 |
|------|-------------|-------------|------|
| 运行时 | Bun >= 1.3.11 | .NET 10 + AOT | 单一二进制,跨平台 |
| 语言 | TypeScript 6 ESM | C# 13 | record types, pattern matching |
| 终端 UI | React 19 + Ink 6 | **Terminal.Gui v2** | 唯一成熟 .NET TUI组件模型接近 React |
| 输出渲染 | Ink 内置 | **Spectre.Console** | 表格/面板/进度条/富文本 |
| CLI 解析 | Commander.js | **System.CommandLine v2** | 官方框架,子命令/选项/补全 |
| Schema 校验 | Zod v4 | **FluentValidation** + **JsonSchema.Net** | POCO 校验 + JSON Schema |
| 代码搜索 | ripgrep (bundled) | ripgrep via Process wrapper | 保持 ripgrep 性能 |
| LSP 客户端 | vscode-languageserver-protocol | **OmniSharp.Extensions.LanguageServer** | 最成熟 .NET LSP 库 |
| MCP 协议 | @modelcontextprotocol/sdk | **自研 MCP SDK** | 社区无成熟方案 |
| HTTP | Bun fetch | **HttpClient + Refit** | 强类型 REST流式支持 |
| OAuth | 自定义 client | **MSAL.NET + IdentityModel.OidcClient** | 成熟 OAuth/OIDC |
| WebSocket | ws (npm) | **System.Net.WebSockets + SignalR** | 内建 + 高层抽象 |
| 进程管理 | Bun spawn | **System.Diagnostics.Process** | 内建跨平台 |
| JSON | JSON.parse/stringify | **System.Text.Json** | 高性能 AOT 友好 |
| 后台任务 | 自定义 | **BackgroundService + Channels** | .NET 标准模式 |
| 插件加载 | 动态 import() | **AssemblyLoadContext** | 运行时程序集隔离 |
| DI | 无 | **Microsoft.Extensions.DependencyInjection** | 标准 DI |
| 日志 | console | **Microsoft.Extensions.Logging** | 结构化日志 |
> 每个映射条目的详细说明,以及完整 NuGet 包版本清单,见: [./reference/技术栈映射说明.md](reference/技术栈映射说明.md)
---
## 3. 顶层架构设计
系统分五层,从下往上依次为基础层、基础设施层、核心服务层、应用层、表现层。
```
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ Terminal.Gui v2 (REPL) │ System.CommandLine (CLI) │
├─────────────────────────────────────────────────────────────────┤
│ Application Layer │
│ REPL Controller │ Command Dispatcher │ QueryEngine │
├─────────────────────────────────────────────────────────────────┤
│ Core Services Layer │
│ ToolSystem(50+) │ PermissionEngine │ FeatureFlags │
│ AgentSpawner │ Memory/Context │ PromptBuilder │
├─────────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ApiProviderRouter │ McpClientManager │ LspClientManager │
│ OAuthService │ BackgroundTasks │ IDE Bridge │
│ SkillLoader │ PluginLoader │ RemoteSessions │
│ RateLimitService │ NotificationSvc │ VoiceService │
├─────────────────────────────────────────────────────────────────┤
│ Foundation Layer │
│ AppState Store │ Config System │ Logging │ DI Container │
└─────────────────────────────────────────────────────────────────┘
```
**核心数据流**: 用户输入 → CLI/REPL → QueryEngine → PromptBuilder → ApiProvider → LLM 响应 → ToolUse 循环 → 渲染输出
每一层仅依赖其下层,不允许跨层向上依赖。基础设施层中的 MCP SDK 是自研实现,因为社区目前没有成熟的 .NET 方案,详见: [./reference/mcp-sdk-implement.md](reference/mcp-sdk-implement.md)
---
## 4. 解决方案结构
整个项目组织为一个 .sln 文件管理 17 个 C# 项目,加上 9 个测试项目。
```
FreeCode.sln
├── src/
│ ├── FreeCode/ # 主可执行 (Console App, AOT)
│ ├── FreeCode.Core/ # 核心接口 + 模型 (25+ 接口, 20+ 模型)
│ ├── FreeCode.Engine/ # QueryEngine + PromptBuilder
│ ├── FreeCode.Tools/ # 50+ Agent 工具
│ │ ├── FileSystem/ (Read, Edit, Write, Glob, Grep, NotebookEdit)
│ │ ├── Shell/ (Bash, PowerShell)
│ │ ├── Agent/ (Agent, Skill, Task* tools)
│ │ ├── Web/ (WebFetch, WebSearch)
│ │ ├── Mcp/ (ListMcpResources, ReadMcpResource, ToolSearch)
│ │ ├── UserInteraction/ (AskUserQuestion, Brief)
│ │ ├── PlanMode/ (EnterPlan, ExitPlan)
│ │ ├── AgentSwarms/ (SendMessage, TeamCreate, TeamDelete)
│ │ └── Worktree/ (EnterWorktree, ExitWorktree)
│ ├── FreeCode.Commands/ # 70+ Slash 命令
│ ├── FreeCode.ApiProviders/ # 5 LLM 提供商
│ ├── FreeCode.Mcp/ # MCP 协议 (Transport + Protocol + ToolWrapper)
│ ├── FreeCode.Lsp/ # LSP 集成
│ ├── FreeCode.Bridge/ # IDE 桥接
│ ├── FreeCode.Services/ # 业务服务 (Auth/Memory/Voice/Remote/Notification/...)
│ ├── FreeCode.Tasks/ # 后台任务管理
│ ├── FreeCode.Skills/ # 技能系统
│ ├── FreeCode.Plugins/ # 插件系统 (AssemblyLoadContext)
│ ├── FreeCode.Features/ # 特性开关
│ ├── FreeCode.State/ # 状态管理 (AppState record)
│ └── FreeCode.TerminalUI/ # Terminal.Gui 组件 (REPL/Components/Theme/Keybindings)
├── tests/ # 9 个测试项目
└── scripts/ # build.ps1, build.sh, publish.sh
```
> 每个项目的职责边界、依赖关系图和 `Directory.Build.props` 共享配置,详见: [./reference/解决方案结构说明.md](reference/解决方案结构说明.md)
---
## 5. 关键设计决策
这些决策在整个设计方案中一以贯之,影响所有模块的实现方式。
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 终端 UI 框架 | Terminal.Gui v2 | .NET 生态内唯一成熟的 TUI 方案 |
| CLI 解析框架 | System.CommandLine v2 | 官方库v2 已稳定,子命令/选项/自动补全齐备 |
| MCP 协议客户端 | 自研 SDK | 社区目前没有成熟的 .NET 实现 |
| 插件隔离方式 | AssemblyLoadContext | .NET 原生支持,程序集可卸载 |
| 状态管理模式 | Record + Event | 不可变状态 + 事件驱动,类似 Redux/Elm |
| AOT 兼容策略 | Source Generator 优先 | 尽量避免反射,确保 AOT 编译不裁剪 |
| 代码搜索 | ripgrep 进程包装 | 保持原有 ripgrep 的搜索性能 |
---
## 模块覆盖对照表
> 说明:
> - ✅ 已覆盖:已有独立设计文档
> - ↗️ 归属:作为其他文档的小节或附属内容覆盖
> - 📋 空目录/stub当前快照为空目录、占位文件或仅剩 stub不单独出文档
| 原始 ../../src/ 目录 | 文件数 | docs/ 覆盖文档 | 状态 |
|------|------:|------|------|
| `entrypoints` | 11 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ✅ 已覆盖 |
| `screens` | 3 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ↗️ 归属 |
| `QueryEngine.ts` | 1 | [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) | ✅ 已覆盖 |
| `tools.ts``tools/` | 210 | [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md) | ✅ 已覆盖 |
| `commands.ts``commands/` | 218 | [核心模块设计 — 命令系统](../核心模块设计/核心模块设计-命令系统.md) | ✅ 已覆盖 |
| `services/api/` | 39 | [核心模块设计 — API 多提供商路由](../核心模块设计/核心模块设计-API提供商路由.md) | ✅ 已覆盖 |
| `services/lsp/` | 7 | [基础设施设计 — LSP集成](../基础设施设计/基础设施设计-LSP集成.md) | ✅ 已覆盖 |
| `services/mcp/` | 22 | [基础设施设计 — MCP协议集成](../基础设施设计/基础设施设计-MCP协议集成.md) | ✅ 已覆盖 |
| `services/oauth/` | 7 | [服务子系统设计 — 认证与OAuth](../服务子系统设计/服务子系统设计-认证与OAuth.md) | ✅ 已覆盖 |
| `services/SessionMemory/` | 3 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ✅ 已覆盖 |
| `services/contextCollapse/` | 3 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 |
| `services/extractMemories/` | 2 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 |
| `services/teamMemorySync/` | 5 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/autoDream/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/plugins/` | 3 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | ✅ 已覆盖 |
| `services/PromptSuggestion/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/policyLimits/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/tips/` | 3 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/analytics/` | 8 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/voice*` | 3 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/settingsSync/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `services/bootstrap/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `bridge/` | 32 | [基础设施设计 — IDE桥接](../基础设施设计/基础设施设计-IDE桥接.md) | ✅ 已覆盖 |
| `tasks/` | 14 | [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) | ✅ 已覆盖 |
| `state/` | 6 | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) | ✅ 已覆盖 |
| `skills/` | 50 | [UI与扩展设计 — 技能系统](../UI与扩展设计/UI与扩展设计-技能系统.md) | ✅ 已覆盖 |
| `plugins/` | 2 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | ✅ 已覆盖 |
| `coordinator/` | 2 | [核心模块设计 — 多代理协调器 (Coordinator)](../核心模块设计/核心模块设计-多代理协调.md) | ✅ 已覆盖 |
| `assistant/` | 5 | [核心模块设计 — 多代理协调器 (Coordinator)](../核心模块设计/核心模块设计-多代理协调.md) | ↗️ 归属 |
| `context/` | 9 | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md) | ↗️ 归属 |
| `hooks/` | 104 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `components/` | 400 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `ink/` | 98 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `ink.ts` | 1 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `keybindings/` | 14 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `constants/` | 22 | [总体概述与技术选型](总体概述与技术选型.md) / 各子模块引用 | ↗️ 归属 |
| `utils/` | 577 | 各模块与基础设施文档的公共支撑 | ↗️ 归属 |
| `types/` | 12 | 各模块与基础设施文档的公共支撑 | ↗️ 归属 |
| `remote/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `daemon/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `proactive/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | ↗️ 归属 |
| `server/` | 11 | [基础设施设计 — 其他基础设施子系统](reference/解决方案结构说明.md) | ↗️ 归属 |
| `vim/` | 5 | [UI与扩展设计 — Terminal-Gui 终端UI](../UI与扩展设计/UI与扩展设计-Terminal-Gui终端UI.md) | ↗️ 归属 |
| `memdir/` | 9 | [服务子系统设计 — 会话记忆与上下文](../服务子系统设计/服务子系统设计-会话记忆与上下文.md) | ↗️ 归属 |
| `migrations/` | 11 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `bootstrap/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `buddy/` | 6 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `cli/` | 21 | [核心模块设计 — CLI启动与解析](../核心模块设计/核心模块设计-CLI启动与解析.md) | ↗️ 归属 |
| `query/` | 4 | [核心模块设计 — 查询引擎 (QueryEngine)](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md) | ↗️ 归属 |
| `self-hosted-runner/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `ssh/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `native-ts/` | 4 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `upstreamproxy/` | 2 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `environment-runner/` | 1 | [服务子系统设计 — 其他服务子系统](../服务子系统设计/服务子系统设计-其他服务子系统.md) | 📋 空目录/stub |
| `jobs/` | 1 | [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md) | ↗️ 归属 |
| `moreright/` | 1 | [UI与扩展设计 — 插件系统](../UI与扩展设计/UI与扩展设计-插件系统.md) | 📋 空目录/stub |
### 备注
- `services/*` 采用“核心子系统 + 其他服务子系统”组合覆盖,避免为每个轻量子目录单独出文档。
- `constants/``types/``utils/` 属于横切支撑层,通常作为被引用内容而非独立模块文档。
- `migrations/``bootstrap/``buddy/``daemon/``proactive/``remote/` 等目录在当前重写文档体系中主要作为其他模块的实现细节归属,不单独展开。
---
## 参考文档
| 文档 | 内容 |
|------|------|
| [.NET 10 平台介绍](reference/.NET-10-平台介绍.md) | AOT、C# 13、核心 API 特性 |
| [技术栈映射说明](reference/技术栈映射说明.md) | TS → .NET 完整映射 + NuGet 清单 |
| [解决方案结构说明](reference/解决方案结构说明.md) | 17 个项目职责与依赖关系 |
| [MCP SDK 实现](reference/mcp-sdk-implement.md) | 自研 MCP SDK 协议与传输层设计 |

View File

@ -0,0 +1,22 @@
# 服务子系统设计 — 原始代码映射
## 元数据
- 项目名称: free-code
- 文档类型: 代码映射
- 原始代码来源: `../../../src/services/``../../../src/utils/memory/`
- 关联总览: [服务子系统设计总览](../服务子系统设计.md)
## 映射表
| 文档模块 | 原始代码路径 | 说明 |
|---|---|---|
| 会话记忆与上下文 | `../../../src/utils/memory/` | 会话记忆、梦境摘要、团队同步逻辑 |
| 远程会话管理 | `../../../src/services/remote/` | 远程连接、事件流、状态机 |
| 语音输入 | `../../../src/services/voice/` | 语音采集、识别与授权门控 |
| 通知服务 | `../../../src/services/notification/` | 终端通知与平台探测 |
| 限流服务 | `../../../src/services/rate-limit/` | 头部解析、重试策略、退避控制 |
| 伙伴系统 | `../../../src/services/companion/` | 确定性角色生成与外观枚举 |
## 说明
- 该映射仅记录服务子系统相关文件夹,不展开到单个实现文件,便于与设计文档保持一一对应。
- 所有条目遵循 .NET 命名风格,接口以 `I` 开头,类型使用 PascalCase。

View File

@ -0,0 +1,170 @@
# 服务子系统设计 — 会话记忆与上下文
## 元数据
- 项目名称: free-code
- 文档类型: 服务子系统设计
- 原始代码来源: `../../src/utils/memory/`
- 关联总览: [服务子系统设计总览](服务子系统设计.md)
- 原始映射: [原始代码映射](reference/原始代码映射-服务子系统.md)
- 相关模块: [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- 相关模块: [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)
- 相关模块: [基础设施设计 — 特性开关](../UI与扩展设计/UI与扩展设计-特性开关系统.md)
## 1. 设计目标
会话记忆与上下文子系统负责把长会话中的关键内容压缩、沉淀、同步,并在需要时为查询引擎提供可控、低成本的上下文补充。该子系统强调阈值触发、轻量模型处理、以及在写入团队知识库前的安全校验。
## 2. 核心数据结构
```csharp
public enum MemoryType
{
Session,
Dream,
Team
}
public sealed record MemoryEntry(
string Id,
MemoryType Type,
string SessionId,
string Content,
DateTimeOffset CreatedAt,
IReadOnlyList<string> Tags);
```
`MemoryType` 用于区分会话记忆、自动梦境摘要与团队共享记忆。`MemoryEntry` 记录统一承载记忆内容、来源会话、创建时间和标签,便于检索与同步。
## 3. 会话记忆服务
```csharp
public interface ISessionMemoryService
{
Task<MemoryEntry?> ExtractAsync(
string sessionId,
int tokenCount,
int toolCallCount,
CancellationToken cancellationToken = default);
Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MemoryEntry>> GetRecentAsync(
string sessionId,
CancellationToken cancellationToken = default);
}
```
`ISessionMemoryService` 使用阈值触发提取:当 token 数或 tool call 数超过阈值时,触发一次记忆抽取,生成摘要型 `MemoryEntry`
```csharp
public sealed class SessionMemoryService : ISessionMemoryService
{
private readonly int _tokenThreshold = 12000;
private readonly int _toolCallThreshold = 24;
public async Task<MemoryEntry?> ExtractAsync(
string sessionId,
int tokenCount,
int toolCallCount,
CancellationToken cancellationToken = default)
{
var shouldExtract = tokenCount >= _tokenThreshold || toolCallCount >= _toolCallThreshold;
if (!shouldExtract)
{
return null;
}
return await Task.FromResult(new MemoryEntry(
Id: Guid.NewGuid().ToString("N"),
Type: MemoryType.Session,
SessionId: sessionId,
Content: "基于会话上下文生成的压缩记忆。",
CreatedAt: DateTimeOffset.UtcNow,
Tags: new[] { "session", "summary" }));
}
public Task SaveAsync(MemoryEntry entry, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<IReadOnlyList<MemoryEntry>> GetRecentAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<MemoryEntry>>(Array.Empty<MemoryEntry>());
}
```
## 4. 自动梦境服务
```csharp
public interface IAutoDreamService
{
Task<MemoryEntry?> GenerateAsync(
string sessionId,
TimeSpan idleDuration,
int sessionCount,
CancellationToken cancellationToken = default);
}
```
`IAutoDreamService` 在两类条件触发:会话空闲超过 24 小时,或会话累计次数达到设定阈值。它用于生成更高层级的长期偏好、主题趋势和待办线索。
```csharp
public sealed class AutoDreamService : IAutoDreamService
{
private readonly TimeSpan _idleThreshold = TimeSpan.FromHours(24);
private readonly int _sessionCountThreshold = 10;
public async Task<MemoryEntry?> GenerateAsync(
string sessionId,
TimeSpan idleDuration,
int sessionCount,
CancellationToken cancellationToken = default)
{
if (idleDuration < _idleThreshold && sessionCount < _sessionCountThreshold)
{
return null;
}
return await Task.FromResult(new MemoryEntry(
Id: Guid.NewGuid().ToString("N"),
Type: MemoryType.Dream,
SessionId: sessionId,
Content: "基于空闲周期与会话积累生成的自动梦境摘要。",
CreatedAt: DateTimeOffset.UtcNow,
Tags: new[] { "dream", "long-term" }));
}
}
```
## 5. 团队记忆同步服务
```csharp
public interface ITeamMemorySyncService
{
Task PushAsync(MemoryEntry entry, CancellationToken cancellationToken = default);
Task<MemoryEntry?> PullAsync(string memoryId, CancellationToken cancellationToken = default);
}
```
`ITeamMemorySyncService` 支持推送与拉取团队记忆。推送前必须执行 secret scanning避免把敏感信息写入共享知识库。
```csharp
public sealed class TeamMemorySyncService : ITeamMemorySyncService
{
public Task PushAsync(MemoryEntry entry, CancellationToken cancellationToken = default)
{
var hasSecret = false;
if (hasSecret)
{
throw new InvalidOperationException("检测到敏感信息,禁止推送团队记忆。");
}
return Task.CompletedTask;
}
public Task<MemoryEntry?> PullAsync(string memoryId, CancellationToken cancellationToken = default)
=> Task.FromResult<MemoryEntry?>(null);
}
```
## 6. 设计说明
- 记忆相关操作使用轻量模型 `claude-haiku-4-5`,优先保证低延迟与低成本。
- 记忆抽取优先走阈值触发,避免每轮上下文都进行重处理。
- 团队记忆同步前必须进行 secret scanning防止把密钥、令牌或隐私数据外发。
- 该子系统与查询引擎联动,用于补充局部上下文,而不是替代主对话历史。

View File

@ -0,0 +1,166 @@
# 服务子系统设计 — 其他服务子系统
## 元数据
- 项目名称: free-code
- 文档类型: 服务子系统设计
- 原始代码来源: `../../src/services/`
- 关联总览: [服务子系统设计总览](服务子系统设计.md)
- 原始映射: [原始代码映射](reference/原始代码映射-服务子系统.md)
- 相关模块: [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)
- 相关模块: [UI与扩展设计 — 特性开关系统](../UI与扩展设计/UI与扩展设计-特性开关系统.md)
## 21.1 远程会话
```csharp
public interface IRemoteSessionManager
{
Task ConnectAsync(string endpoint, CancellationToken cancellationToken = default);
Task DisconnectAsync(CancellationToken cancellationToken = default);
IAsyncEnumerable<RemoteEvent> ReadEventsAsync(CancellationToken cancellationToken = default);
}
public abstract record RemoteEvent(DateTimeOffset Timestamp);
public sealed record RemoteConnectedEvent(DateTimeOffset Timestamp, string SessionId) : RemoteEvent(Timestamp);
public sealed record RemoteDisconnectedEvent(DateTimeOffset Timestamp, string Reason) : RemoteEvent(Timestamp);
public sealed record RemoteMessageEvent(DateTimeOffset Timestamp, string Message) : RemoteEvent(Timestamp);
public enum RemoteConnectionStatus
{
Disconnected,
Connecting,
Connected,
Reconnecting,
Failed
}
```
远程会话管理用于把本地交互扩展到远端主机或托管环境,并通过事件流表达连接状态、消息和断连原因。
## 21.2 语音输入
```csharp
public interface IVoiceService
{
Task StartAsync(CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
Task<string?> RecognizeAsync(CancellationToken cancellationToken = default);
}
public sealed class VoiceService : IVoiceService
{
public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<string?> RecognizeAsync(CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
}
```
语音输入采用特性开关与 OAuth 门控:只有启用对应功能且完成授权后,语音能力才会暴露给会话流程。
## 21.3 通知服务
```csharp
public interface INotificationService
{
Task NotifyAsync(string title, string message, CancellationToken cancellationToken = default);
bool IsSupportedTerminal();
}
public sealed class NotificationService : INotificationService
{
public Task NotifyAsync(string title, string message, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public bool IsSupportedTerminal()
{
var terminal = Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? string.Empty;
return terminal is "iTerm2" or "Kitty" or "Ghostty";
}
}
```
通知服务依赖终端类型检测,在 iTerm2、Kitty、Ghostty 等环境中可提供更稳定的本地通知体验。
## 21.4 限流服务
```csharp
public interface IRateLimitService
{
bool CanProceed(IDictionary<string, string> headers);
TimeSpan? GetRetryAfter(IDictionary<string, string> headers);
}
public sealed class RateLimitService : IRateLimitService
{
public bool CanProceed(IDictionary<string, string> headers)
=> !GetRetryAfter(headers).HasValue;
public TimeSpan? GetRetryAfter(IDictionary<string, string> headers)
{
foreach (var pair in headers)
{
if (pair.Key.StartsWith("anthropic-ratelimit-tokens-", StringComparison.OrdinalIgnoreCase))
{
return TimeSpan.FromSeconds(30);
}
}
return null;
}
}
```
限流服务通过解析 `anthropic-ratelimit-tokens-*` 头部推断令牌配额状态,并用于动态退避与重试控制。
## 21.5 伙伴系统
```csharp
public interface ICompanionService
{
Companion Create(string seed);
}
public sealed class CompanionService : ICompanionService
{
public Companion Create(string seed)
{
var prng = new Mulberry32(seed.GetHashCode());
return new Companion(
Species: Species.Cat,
Eye: Eye.Blue,
Hat: Hat.Bowler,
Rarity: Rarity.Rare,
Name: $"Companion-{prng.NextInt(1000)}");
}
}
public sealed class Mulberry32
{
private uint _state;
public Mulberry32(int seed) => _state = (uint)seed;
public int NextInt(int max) => (int)(NextUInt() % (uint)max);
private uint NextUInt()
{
_state += 0x6D2B79F5;
var t = _state;
t = (t ^ (t >> 15)) * (t | 1);
t ^= t + (t ^ (t >> 7)) * (t | 61);
return t ^ (t >> 14);
}
}
public sealed record Companion(Species Species, Eye Eye, Hat Hat, Rarity Rarity, string Name);
public enum Species { Cat, Dog, Fox, Owl }
public enum Eye { Blue, Green, Gold, Red }
public enum Hat { None, Bowler, Cap, Crown }
public enum Rarity { Common, Uncommon, Rare, Epic, Legendary }
```
伙伴系统使用确定性生成逻辑:相同种子输入会产生相同角色结果,便于同步、复现和测试。
## 21.6 交叉引用
- [服务子系统设计总览](服务子系统设计.md)
- [原始代码映射](reference/原始代码映射-服务子系统.md)
- [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)
- [UI与扩展设计 — 特性开关系统](../UI与扩展设计/UI与扩展设计-特性开关系统.md)

View File

@ -0,0 +1,304 @@
# 服务子系统设计 — 认证与 OAuth
> **所属项目**: free-code .NET 10 重写
> **文档类型**: 子模块设计
> **原始源码**: `../../src/services/oauth/`
> **配套文档**: [服务子系统设计总览](服务子系统设计.md) | [参考映射](reference/原始代码映射-服务子系统.md)
---
## 概述
认证模块负责管理用户身份凭证的完整生命周期,涵盖 OAuth 授权流程启动、回调处理、token 交换、安全存储和登出。原始 TypeScript 实现包含两条独立的 OAuth 流程Anthropic 控制台 OAuth 和 OpenAI Codex OAuth均通过本地 HTTP 监听器完成浏览器回调拦截。
.NET 10 重写将认证能力抽象为 `IAuthService` 接口,安全存储单独抽象为 `ISecureTokenStorage`允许在不同平台macOS Keychain、Windows DPAPI、Linux Secret Service下替换存储后端。
---
## 19.1 IAuthService 接口
```csharp
/// <summary>
/// 认证服务接口 — 管理 OAuth 登录、登出和 token 获取
/// 对应原始 ../../src/services/oauth/ 目录的整体能力
/// </summary>
public interface IAuthService
{
/// <summary>当前是否已通过任意提供商完成认证</summary>
bool IsAuthenticated { get; }
/// <summary>是否为 Claude.ai 用户(通过 claudeai_token 判断)</summary>
bool IsClaudeAiUser { get; }
/// <summary>是否为 Anthropic 内部用户</summary>
bool IsInternalUser { get; }
/// <summary>
/// 启动 OAuth 授权流程
/// </summary>
/// <param name="provider">提供商名称,支持 "anthropic" 和 "codex"</param>
Task LoginAsync(string provider = "anthropic");
/// <summary>清除所有本地存储的 token退出登录</summary>
Task LogoutAsync();
/// <summary>获取当前有效的 OAuth access token未登录时返回 null</summary>
Task<string?> GetOAuthTokenAsync();
/// <summary>认证状态发生变化时触发(登录或登出后)</summary>
event EventHandler? AuthStateChanged;
}
```
---
## 19.2 AuthService 实现
```csharp
/// <summary>
/// 认证服务实现 — Anthropic OAuth + Codex OAuth 双流程
/// 对应原始 ../../src/services/oauth/anthropic.ts 和 openai.ts
/// </summary>
public sealed class AuthService : IAuthService
{
private readonly ISecureTokenStorage _tokenStorage;
private readonly ILogger<AuthService> _logger;
public bool IsAuthenticated => _tokenStorage.Get("oauth_token") != null;
public bool IsClaudeAiUser => _tokenStorage.Get("claudeai_token") != null;
public bool IsInternalUser => false; // Anthropic 内部用户标记,外部构建始终为 false
public event EventHandler? AuthStateChanged;
public AuthService(ISecureTokenStorage tokenStorage, ILogger<AuthService> logger)
{
_tokenStorage = tokenStorage;
_logger = logger;
}
/// <summary>
/// Anthropic OAuth 流程
/// 1. 启动本地 HTTP 监听器(拦截浏览器回调)
/// 2. 构建授权 URL 并打开浏览器
/// 3. 等待回调获取 authorization code
/// 4. 用 code 交换 access token + refresh token
/// 5. 写入安全存储
/// </summary>
public async Task LoginAsync(string provider = "anthropic")
{
if (provider == "anthropic")
{
// 1. 启动本地 HTTP 监听器(获取 OAuth 回调)
var callbackPort = GetAvailablePort();
using var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{callbackPort}/");
listener.Start();
// 2. 构建 OAuth 授权 URL
var authUrl = $"https://console.anthropic.com/oauth/authorize"
+ $"?client_id=free-code-cli"
+ $"&redirect_uri=http://localhost:{callbackPort}/"
+ $"&response_type=code"
+ $"&scope=openid profile email";
// 3. 打开浏览器
OpenBrowser(authUrl);
// 4. 等待回调 → 获取 code
var context = await listener.GetContextAsync();
var code = context.Request.QueryString["code"];
// 5. 交换 token
using var httpClient = new HttpClient();
var tokenResponse = await httpClient.PostAsync(
"https://console.anthropic.com/oauth/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code ?? "",
["redirect_uri"] = $"http://localhost:{callbackPort}/",
["client_id"] = "free-code-cli",
}));
var tokens = await tokenResponse.Content.ReadFromJsonAsync<OAuthTokens>();
_tokenStorage.Set("oauth_token", tokens?.AccessToken);
_tokenStorage.Set("oauth_refresh_token", tokens?.RefreshToken);
}
else if (provider == "codex")
{
// OpenAI Codex OAuth — 不同端点,流程相同
var authUrl = "https://auth.openai.com/authorize"
+ "?client_id=codex-cli"
+ "&response_type=code"
+ "&scope=openid profile email";
// 后续 code exchange 流程与 Anthropic 流程对称
}
AuthStateChanged?.Invoke(this, EventArgs.Empty);
}
public Task LogoutAsync()
{
_tokenStorage.Remove("oauth_token");
_tokenStorage.Remove("oauth_refresh_token");
_tokenStorage.Remove("claudeai_token");
AuthStateChanged?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public Task<string?> GetOAuthTokenAsync() =>
Task.FromResult(_tokenStorage.Get("oauth_token"));
private static int GetAvailablePort()
{
using var socket = new System.Net.Sockets.TcpListener(
System.Net.IPAddress.Loopback, 0);
socket.Start();
var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port;
socket.Stop();
return port;
}
private static void OpenBrowser(string url)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
Process.Start("open", url);
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Process.Start("xdg-open", url);
else
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
/// <summary>
/// OAuth token 响应的反序列化模型
/// </summary>
internal sealed record OAuthTokens
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; init; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
```
---
## 19.3 ISecureTokenStorage 接口
```csharp
/// <summary>
/// 安全 token 存储接口 — 平台无关
/// macOS: Keychain / Windows: DPAPI / Linux: Secret Service
/// </summary>
public interface ISecureTokenStorage
{
/// <summary>读取指定键的 token不存在时返回 null</summary>
string? Get(string key);
/// <summary>写入指定键的 tokenvalue 为 null 时等同于 Remove</summary>
void Set(string key, string? value);
/// <summary>删除指定键的 token</summary>
void Remove(string key);
}
```
---
## 19.4 KeychainTokenStorage — macOS Keychain 集成
```csharp
/// <summary>
/// macOS Keychain 安全存储实现
/// 对应原始代码中对系统 Keychain 的直接访问
/// 通过 security CLI 工具与 macOS Keychain Services 通信
/// </summary>
public sealed class KeychainTokenStorage : ISecureTokenStorage
{
/// <summary>
/// 读取 Keychain 中的 token
/// 等效命令: security find-generic-password -s free-code-{key} -w
/// </summary>
public string? Get(string key)
{
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"find-generic-password -s free-code-{key} -w",
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
var output = process?.StandardOutput.ReadToEnd().Trim();
return string.IsNullOrEmpty(output) ? null : output;
}
/// <summary>
/// 写入 token 到 Keychain-U 标志: 不存在则创建,存在则更新)
/// 等效命令: security add-generic-password -U -s free-code-{key} -p {value}
/// </summary>
public void Set(string key, string? value)
{
if (value == null) { Remove(key); return; }
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"add-generic-password -U -s free-code-{key} -p {value}",
CreateNoWindow = true,
};
Process.Start(psi)?.WaitForExit();
}
/// <summary>
/// 从 Keychain 删除 token
/// 等效命令: security delete-generic-password -s free-code-{key}
/// </summary>
public void Remove(string key)
{
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"delete-generic-password -s free-code-{key}",
CreateNoWindow = true,
};
Process.Start(psi)?.WaitForExit();
}
}
```
---
## 设计说明
**本地 HTTP 监听器模式**
OAuth 授权码流程要求一个回调 URI。命令行工具无法注册自定义 URL Scheme不同于桌面应用因此采用本地 HTTP 监听器方案:随机选取可用端口,启动监听后打开浏览器,用户授权后浏览器重定向到 `localhost:{port}/?code=...`CLI 截获 code 完成交换。这是命令行 OAuth 的通行做法,与 GitHub CLI、Azure CLI 的实现模式一致。
**Keychain 使用 CLI 而非 P/Invoke**
原始 TypeScript 代码通过 Node.js 调用 `security` 命令行工具,.NET 重写保持相同策略。使用 `security` CLI 而非直接调用 Keychain Services C API避免了 macOS 10.15+ 的 hardened runtime 代码签名限制,也无需 entitlements 配置。代价是每次读写有子进程启动开销,但认证操作频率极低,可以接受。
**跨平台存储抽象**
`ISecureTokenStorage` 接口的引入使 DI 容器可以按运行平台注入不同实现。注册时可这样处理:
```csharp
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
services.AddSingleton<ISecureTokenStorage, KeychainTokenStorage>();
else
services.AddSingleton<ISecureTokenStorage, EncryptedFileTokenStorage>();
```
---
## 参考资料
- [服务子系统设计总览](服务子系统设计.md)
- [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md)
- [核心模块设计 — API 提供商路由](../核心模块设计/核心模块设计-API提供商路由.md)

View File

@ -0,0 +1,93 @@
# 服务子系统设计 — 总览
> **所属项目**: free-code .NET 10 重写
> **文档类型**: 模块索引
> **对应源码**: `../../src/services/oauth/`, `../../src/utils/memory/`, `../../src/voice/`
> **配套文档**: [总体概述](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [基础设施设计](../基础设施设计/基础设施设计.md)
---
## 概述
服务子系统层封装了 free-code 运行时所需的各类横切关注点,包括身份认证、会话记忆、语音输入、通知推送、速率限制跟踪和同伴系统。这一层不直接参与 LLM 查询主路径,而是为核心模块和 UI 层提供基础能力。
原始 TypeScript 实现中,这些服务分散于 `../../src/services/oauth/``../../src/utils/memory/``../../src/voice/``../../src/buddy/` 等多个目录。.NET 10 重写将其整理为三个职责清晰的文档,每个服务通过接口向上层暴露,内部实现完全封装。
---
## 架构概览
```
服务子系统
├── IAuthService 认证与 OAuthAnthropic + Codex
│ └── ISecureTokenStorage Keychain / 跨平台安全存储
├── ISessionMemoryService 会话记忆提取(阈值触发)
│ └── IAutoDreamService 后台记忆合并24h / 会话数触发)
│ └── ITeamMemorySyncService 团队记忆 Git 同步
└── 其他服务
├── IRemoteSessionManager 远程会话管理
├── IVoiceService 语音输入push-to-talk
├── INotificationService 终端通知iTerm2 / Kitty / Ghostty
├── IRateLimitService API 速率限制跟踪
└── ICompanionService 同伴系统(确定性 ASCII 宠物)
```
---
## 子模块列表
### [认证与 OAuth](服务子系统设计-认证与OAuth.md)
覆盖 `IAuthService` 接口、`AuthService` 实现Anthropic 和 Codex OAuth 流程),以及 `ISecureTokenStorage` 与 macOS Keychain 的集成。
- 原始源码: `../../src/services/oauth/`
- 核心类型: `IAuthService``AuthService``ISecureTokenStorage``KeychainTokenStorage`
---
### [会话记忆与上下文](服务子系统设计-会话记忆与上下文.md)
覆盖 `ISessionMemoryService` 的阈值触发提取机制、`IAutoDreamService` 的后台记忆合并循环,以及 `ITeamMemorySyncService` 的 Git 推拉与秘密扫描流程。
- 原始源码: `../../src/utils/memory/`
- 核心类型: `ISessionMemoryService``SessionMemoryService``IAutoDreamService``AutoDreamService``ITeamMemorySyncService``TeamMemorySyncService`
---
### [其他服务子系统](服务子系统设计-其他服务子系统.md)
覆盖远程会话、语音输入、终端通知、API 速率限制跟踪和同伴Buddy系统的完整接口与实现设计。
- 原始源码: `../../src/voice/``../../src/buddy/`、notification 系统、rate limit 头解析
- 核心类型: `IRemoteSessionManager``IVoiceService``INotificationService``IRateLimitService``ICompanionService`
---
## 关键设计决策
**接口驱动的安全存储**
原始 TypeScript 代码直接调用 Keychain 命令行工具,与平台深度耦合。.NET 重写通过 `ISecureTokenStorage` 接口隔离平台细节macOS 使用 `KeychainTokenStorage`,其他平台可替换为 DPAPI 或 Secret Service 实现,不影响上层逻辑。
**记忆提取采用轻量模型**
`SessionMemoryService``AutoDreamService` 均使用 `claude-haiku-4-5` 执行提取和合并,而非主会话使用的 Opus/Sonnet 模型。这样既控制了成本,又不阻塞主查询路径(记忆操作在后台异步完成)。
**团队记忆的秘密扫描门控**
`TeamMemorySyncService.PushAsync` 在提交到 Git 之前强制扫描变更内容,任何检测到 API key 或密码模式的内容都会中止推送并抛出异常,防止意外泄露敏感信息。
**同伴生成的确定性保证**
`CompanionService` 使用 `HashString(userId + "friend-2026-401")` 作为种子,通过 Mulberry32 PRNG 生成同伴属性。相同的 userId 永远产生相同的同伴,确保用户在不同设备或重装后看到同一只宠物。
---
## 参考资料
- [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md)
- [核心模块设计 — 查询引擎](../核心模块设计/核心模块设计-查询引擎-QueryEngine.md)
- [基础设施设计 — 后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)

View File

@ -0,0 +1,115 @@
# 原始代码映射 — 核心模块
> 所属项目: free-code .NET 10 重写
> 文档类型: 参考映射表
> 上级文档: [核心模块设计总览](../核心模块设计.md)
---
## 说明
本文档建立 .NET 10 类型与原始 TypeScript 源文件之间的映射关系。每一行说明对应 .NET 类型重写了哪个 TS 文件的哪部分逻辑,以及重写时做出的关键设计决策。
---
## 核心模块映射表
### 启动与 CLI 解析
| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 |
|-----------|-------------|---------|---------|
| `Program` | `../../../src/entrypoints/cli.tsx` | 模块顶层的入口逻辑和条件分支 | 拆分为四阶段,引入 `IHostBuilder` 管理生命周期 |
| `QuickPathHandler` | `../../../src/entrypoints/cli.tsx` | 文件顶部散布的 `process.argv.includes()` 快速路径检查 | 集中到静态类Phase 1 执行,无 DI 开销 |
| `CliCommandBuilder` | `../../../src/entrypoints/cli.tsx` | Commander.js 的 `program.option(...).action(...)` 配置 | 映射到 `System.CommandLine``RootCommand` + `Option<T>` |
| `OneShotMode` | `../../../src/entrypoints/cli.tsx` | `-p` 参数触发的非交互式执行路径 | 独立类,与 REPL 模式分离 |
| `REPLMode` | `../../../src/screens/REPL.tsx` | React/Ink 交互式 UI 主循环 | 替换为基于 Spectre.Console 或原生终端 API 的 .NET UI |
---
### 查询引擎
| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 |
|-----------|-------------|---------|---------|
| `IQueryEngine` | `../../../src/QueryEngine.ts` | 隐式的模块导出函数签名 | 显式接口,便于测试替换 |
| `QueryEngine` | `../../../src/QueryEngine.ts` | 核心消息循环(约 800 行)| 提取 System Prompt 构建为独立类;`IAsyncEnumerable` 替换回调式流 |
| `SubmitMessageOptions` | `../../../src/QueryEngine.ts` | `query()` 函数的参数对象 | `record` 类型,不可变,编译期类型安全 |
| `SystemPromptBuilder` | `../../../src/QueryEngine.ts` | 内联的 `getSystemPrompt()` 函数(约 200 行)| 提取为独立接口 `IPromptBuilder`,支持 DI 替换 |
| `SDKMessage`(联合类型)| `../../../src/types/message.ts` | `SDKMessage` TypeScript 联合类型 | C# discriminated union 通过继承体系实现 |
| `TokenUsage` | `../../../src/QueryEngine.ts` | 内联的 token 统计逻辑 | 独立 record从 API 响应头提取 |
---
### 工具系统
| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 |
|-----------|-------------|---------|---------|
| `ITool` | `../../../src/Tool.ts` | 工具基础接口定义(约 40 行)| 拆分为非泛型 `ITool` 和泛型 `ITool<TInput,TOutput>` 两层 |
| `ITool<TInput,TOutput>` | `../../../src/Tool.ts` | 工具执行方法签名 | 泛型化,输入/输出类型在编译期检查 |
| `ToolBase<TInput,TOutput>` | `../../../src/Tool.ts` | 工具基类的默认实现 | 抽象类,集成 FluentValidation 验证点 |
| `ToolExecutionContext` | `../../../src/Tool.ts` | 工具执行时接收的上下文对象 | `record` 类型,不可变,避免工具意外修改上下文 |
| `ToolCategory` | `../../../src/Tool.ts` | 工具分类枚举 | 直接映射,枚举成员一一对应 |
| `BashTool` | `../../../src/tools/BashTool.tsx` | Bash 工具完整实现 | `child_process.spawn``Process``Promise.race``.WaitAsync(timeout)` |
| `BashToolInput` | `../../../src/tools/BashTool.tsx` | Bash 工具输入类型 | TypeScript 接口 → C# `record`,不可变 |
| `BashToolOutput` | `../../../src/tools/BashTool.tsx` | Bash 工具输出类型 | 同上 |
| `ToolRegistry` | `../../../src/tools.ts` | `getTools()` 函数和工具数组 | 注册表类,懒加载缓存,稳定排序保证 prompt cache |
| `CommandClassifier` | `../../../src/tools/BashTool.tsx` | 命令只读性判断逻辑 | 提取为独立工具类 |
---
### 命令系统
| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 |
|-----------|-------------|---------|---------|
| `ICommand` | `../../../src/commands.ts` | 命令对象的隐式接口约定 | 显式接口,`CommandCategory``CommandAvailability` 枚举化 |
| `CommandContext` | `../../../src/commands.ts` | 命令执行时传入的上下文 | 独立 record包含会话状态和 UI 接口 |
| `CommandResult` | `../../../src/commands.ts` | 命令执行返回值 | `record(bool Success, string? Output)` |
| `CommandRegistry` | `../../../src/commands.ts` | `COMMAND_LIST` 数组及导出函数 | 注册表类,按认证状态和 feature flag 动态过滤 |
| `CommandCategory` | `../../../src/commands.ts` | `type: 'prompt' \| 'local' \| 'local-jsx'` | C# `enum`JSX 对话框模式映射为 `LocalDialog` |
| `CommandAvailability` | `../../../src/commands.ts` | `availability: string` 字段 | C# `enum`,强类型替换字符串约定 |
| 各具体命令类 | `../../../src/commands/` | 各 `*Command.ts` 文件 | 一一对应,每个文件对应一个实现类 |
---
### API 多提供商路由
| .NET 类型 | 原始 TS 文件 | 对应逻辑 | 设计变化 |
|-----------|-------------|---------|---------|
| `IApiProvider` | `../../../src/services/api/` | 各提供商模块的导出函数签名 | 统一抽象接口,所有提供商实现同一接口 |
| `ApiProviderRouter` | `../../../src/services/api/` | `process.env` 条件判断选择客户端 | 路由器类,构造时确定提供商,运行时不再判断 |
| `ApiProviderType` | `../../../src/services/api/` | 字符串常量区分提供商 | C# `enum`,编译期安全 |
| `ApiRequest` | `../../../src/services/api/` | API 调用参数对象 | `record`,不可变 |
| `AnthropicProvider` | `../../../src/services/api/claude.ts` | Anthropic SDK 流式调用(约 2000 行)| 移除 SDK 依赖,直接 `HttpClient` + 手动 SSE 解析 |
| `CodexProvider` | `../../../src/services/api/` | OpenAI Codex 客户端 | 对应原始 Codex fetch adapter |
| `BedrockProvider` | `../../../src/services/api/` | AWS Bedrock 客户端 | 使用 AWS SDK for .NET |
| `VertexProvider` | `../../../src/services/api/` | Google Vertex 客户端 | 使用 Google Cloud SDK for .NET |
| `FoundryProvider` | `../../../src/services/api/` | Anthropic Foundry 客户端 | 基于 `AnthropicProvider` 适配自定义端点 |
---
## 未直接映射的原始文件
以下 `../../../src/` 文件的逻辑分散到了基础设施设计或服务子系统文档中:
| 原始文件 | 重写位置 |
|----------|---------|
| `../../../src/state/` | [基础设施设计 — 状态管理](../../基础设施设计/基础设施设计-状态管理.md) |
| `../../../src/services/mcp*` | [基础设施设计 — MCP 协议集成](../../基础设施设计/基础设施设计-MCP协议集成.md) |
| `../../../src/services/oauth/` | [服务子系统设计 — 认证](../../服务子系统设计/) |
| `../../../src/skills/` | [服务子系统设计 — 技能系统](../../服务子系统设计/) |
| `../../../src/plugins/` | [服务子系统设计 — 插件系统](../../服务子系统设计/) |
| `../../../src/bridge/` | [服务子系统设计 — IDE 桥接](../../服务子系统设计/) |
| `../../../src/tasks/` | [服务子系统设计 — 后台任务](../../服务子系统设计/) |
| `../../../src/voice/` | [服务子系统设计 — 语音输入](../../服务子系统设计/) |
| `../../../src/components/` | [UI 与扩展设计](../../UI与扩展设计/) |
| `../../../src/hooks/` | [UI 与扩展设计](../../UI与扩展设计/) |
---
## 参考资料
- [核心模块设计总览](../核心模块设计.md)
- [CLI 启动与解析](../核心模块设计-CLI启动与解析.md)
- [查询引擎 (QueryEngine)](../核心模块设计-查询引擎-QueryEngine.md)
- [工具系统](../核心模块设计-工具系统.md)
- [命令系统](../核心模块设计-命令系统.md)
- [API 多提供商路由](../核心模块设计-API提供商路由.md)

View File

@ -0,0 +1,200 @@
# 核心模块设计 — API 多提供商路由
> 所属项目: free-code .NET 10 重写
> 原始代码来源: `../../src/services/api/`
> 原始设计意图: 支持五种 API 提供商Anthropic、OpenAI Codex、AWS Bedrock、Google Vertex、Anthropic Foundry的统一路由通过环境变量自动检测活跃提供商并为每个提供商实现 SSE 流式解析
> 上级文档: [核心模块设计总览](核心模块设计.md)
---
## 概述
API 多提供商路由模块是 free-code 多后端能力的核心。它将具体 API 调用细节从 `QueryEngine` 中解耦出来,使查询引擎无需关心当前使用的是哪家服务商的 API。
原始 TypeScript 实现通过 `../../src/services/api/` 目录下的多个文件分别实现各提供商的客户端。.NET 重写引入 `IApiProvider` 接口统一抽象,通过 `ApiProviderRouter` 在运行时选择具体实现。
---
## 9.1 提供商枚举
```csharp
public enum ApiProviderType
{
Anthropic, // 默认,直连 Anthropic API
OpenAICodex, // CLAUDE_CODE_USE_OPENAI=1
AwsBedrock, // CLAUDE_CODE_USE_BEDROCK=1
GoogleVertex, // CLAUDE_CODE_USE_VERTEX=1
AnthropicFoundry // CLAUDE_CODE_USE_FOUNDRY=1
}
```
---
## 9.2 IApiProvider 接口
```csharp
/// <summary>API提供商抽象接口</summary>
public interface IApiProvider
{
/// <summary>发起流式 API 请求,返回 SSE 消息流</summary>
IAsyncEnumerable<SDKMessage> StreamAsync(
ApiRequest request,
CancellationToken ct = default);
}
public record ApiRequest(
string SystemPrompt,
IReadOnlyList<ApiMessage> Messages,
IReadOnlyList<ITool> Tools,
string? Model = null
);
```
---
## 9.3 ApiProviderRouter — 路由器
`ApiProviderRouter` 在构造时检测环境变量,确定活跃提供商,后续每次调用 `GetActiveProvider()` 都返回对应的实例。
**原始设计意图:** 原始代码通过 `process.env` 检查选择不同的 API 客户端模块。.NET 版本将这一逻辑集中在路由器的构造函数中,避免散布在代码各处的条件判断。
```csharp
public class ApiProviderRouter : IApiProviderRouter
{
private readonly IServiceProvider _services;
private ApiProviderType _activeProvider;
public ApiProviderRouter(IServiceProvider services)
{
_services = services;
_activeProvider = DetectProvider(); // 从环境变量自动检测
}
private static ApiProviderType DetectProvider()
{
if (Env("CLAUDE_CODE_USE_OPENAI") == "1") return ApiProviderType.OpenAICodex;
if (Env("CLAUDE_CODE_USE_BEDROCK") == "1") return ApiProviderType.AwsBedrock;
if (Env("CLAUDE_CODE_USE_VERTEX") == "1") return ApiProviderType.GoogleVertex;
if (Env("CLAUDE_CODE_USE_FOUNDRY") == "1") return ApiProviderType.AnthropicFoundry;
return ApiProviderType.Anthropic;
}
public IApiProvider GetActiveProvider() => _activeProvider switch
{
ApiProviderType.Anthropic => _services.GetRequiredService<AnthropicProvider>(),
ApiProviderType.OpenAICodex => _services.GetRequiredService<CodexProvider>(),
ApiProviderType.AwsBedrock => _services.GetRequiredService<BedrockProvider>(),
ApiProviderType.GoogleVertex => _services.GetRequiredService<VertexProvider>(),
ApiProviderType.AnthropicFoundry => _services.GetRequiredService<FoundryProvider>(),
_ => throw new InvalidOperationException()
};
private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
}
```
检测优先级为OpenAI > Bedrock > Vertex > Foundry > Anthropic默认。若同时设置多个环境变量优先级靠前的生效。
---
## 9.4 AnthropicProvider — SSE 流式解析
`AnthropicProvider` 实现直连 Anthropic Messages API 的 SSE 流式解析,是五个提供商中最核心的实现。
**原始设计意图:** 原始 `claude.ts` 使用 Anthropic TypeScript SDK 处理流式响应。.NET 版本直接使用 `HttpClient` 发送 HTTP 请求,手动解析 SSE 事件行,避免引入额外 SDK 依赖。
```csharp
public class AnthropicProvider : IApiProvider
{
private readonly HttpClient _httpClient;
public async IAsyncEnumerable<SDKMessage> StreamAsync(
ApiRequest request, [EnumeratorCancellation] CancellationToken ct = default)
{
var payload = new {
model = request.Model ?? "claude-sonnet-4-6",
max_tokens = 16384,
stream = true,
system = request.SystemPrompt,
messages = request.Messages,
tools = request.Tools
};
var httpReq = new HttpRequestMessage(HttpMethod.Post,
$"{GetBaseUrl()}/v1/messages")
{
Content = JsonContent.Create(payload)
};
httpReq.Headers.Add("x-api-key", GetApiKey());
httpReq.Headers.Add("anthropic-version", "2023-06-01");
using var response = await _httpClient.SendAsync(
httpReq, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
// SSE 流式解析
using var stream = await response.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: ")) continue;
var json = line[6..];
var doc = JsonDocument.Parse(json);
var type = doc.RootElement.GetProperty("type").GetString();
switch (type)
{
case "content_block_delta":
yield return new SDKMessage.StreamingDelta(
doc.RootElement.GetProperty("delta")
.GetProperty("text").GetString()!);
break;
case "content_block_start":
var block = doc.RootElement.GetProperty("content_block");
if (block.GetProperty("type").GetString() == "tool_use")
yield return new SDKMessage.ToolUseStart(
block.GetProperty("id").GetString()!,
block.GetProperty("name").GetString()!,
block.GetProperty("input"));
break;
case "message_stop":
yield break;
}
}
}
}
```
### SSE 事件类型映射
| SSE `type` | 映射到 `SDKMessage` 子类 | 说明 |
|------------|--------------------------|------|
| `content_block_delta` | `SDKMessage.StreamingDelta` | 文本流式增量 |
| `content_block_start` (tool_use) | `SDKMessage.ToolUseStart` | 工具调用开始 |
| `message_stop` | `yield break` | 流结束 |
| 其他 | 忽略 | `ping``message_start` 等元数据事件 |
`HttpCompletionOption.ResponseHeadersRead` 确保在响应头返回后立即开始读取流,而不是等待整个响应体下载完成,这是 SSE 流式解析的关键设置。
---
## 9.5 提供商配置汇总
| 提供商 | 环境变量 | 认证方式 | 端点 |
|--------|----------|----------|------|
| Anthropic | 默认 | `ANTHROPIC_API_KEY` 或 OAuth | `https://api.anthropic.com` |
| OpenAI Codex | `CLAUDE_CODE_USE_OPENAI=1` | OAuth via OpenAI | `https://api.openai.com` |
| AWS Bedrock | `CLAUDE_CODE_USE_BEDROCK=1` | AWS 标准凭证链 | `AWS_REGION` 对应端点 |
| Google Vertex | `CLAUDE_CODE_USE_VERTEX=1` | GCP ADC | GCP 项目对应端点 |
| Anthropic Foundry | `CLAUDE_CODE_USE_FOUNDRY=1` | `ANTHROPIC_FOUNDRY_API_KEY` | 自定义部署端点 |
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md)
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)

View File

@ -0,0 +1,245 @@
# 核心模块设计 — CLI 启动与解析
> 所属项目: free-code .NET 10 重写
> 原始代码来源: `../../src/entrypoints/cli.tsx`, `../../src/screens/REPL.tsx`
> 原始设计意图: 解析命令行参数,区分交互式 REPL 模式和一次性 prompt 模式,并处理 daemon/bridge 等特殊运行模式
> 上级文档: [核心模块设计总览](核心模块设计.md)
> 交叉参考: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)
---
## 概述
CLI 启动模块是整个应用的入口,对应原始 TypeScript 项目的 `cli.tsx`。它的核心职责是在加载任何业务逻辑之前,先判断这次调用属于哪种运行模式,并将控制权交给对应的处理器。
.NET 重写将原始代码中混合在一个文件里的逻辑拆分为三个独立的类:
- `Program` — 四阶段启动协调器
- `QuickPathHandler` — 无需 DI 容器的快速路径处理
- `CliCommandBuilder` — 基于 `System.CommandLine` 的命令行定义
---
## 5.1 入口点 Program.cs
`Program.cs` 组织为四个顺序执行的阶段,每一阶段都有明确的失败边界。
**原始设计意图:** 原始 `cli.tsx` 在模块顶层直接调用 Commander.js 解析参数,然后根据 flag 条件决定走哪条路径。.NET 版本将这一隐式流程显式化为四个阶段,并统一使用 `Microsoft.Extensions.Hosting` 管理生命周期。
```csharp
public static class Program
{
public static async Task<int> Main(string[] args)
{
// Phase 1: 快速路径不加载DI容器零开销
if (QuickPathHandler.TryHandle(args, out var exitCode))
return exitCode;
// Phase 2: 构建Host
var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration(cfg => cfg
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".free-code", "settings.json"), optional: true, reloadOnChange: true))
.ConfigureServices((ctx, services) =>
{
services.AddCoreServices();
services.AddEngine();
services.AddTools();
services.AddCommands();
services.AddApiProviders();
services.AddMcp();
services.AddLsp();
services.AddBridge();
services.AddBusinessServices();
services.AddTasks();
services.AddSkills();
services.AddPlugins();
services.AddFeatures();
services.AddState();
services.AddTerminalUI();
})
.ConfigureLogging(logging => logging
.AddConsole()
.SetMinimumLevel(LogLevel.Information))
.Build();
// Phase 3: 初始化核心服务
await host.Services.GetRequiredService<IAppInitializer>().InitializeAsync();
// Phase 4: 启动REPL或执行一次性命令
var runner = host.Services.GetRequiredService<IAppRunner>();
return await runner.RunAsync(args);
}
}
```
### 四阶段说明
| 阶段 | 职责 | 失败处理 |
|------|------|----------|
| Phase 1 | 快速路径检测,无 DI 开销 | 匹配则直接返回,不进入后续阶段 |
| Phase 2 | 构建 Host注册所有服务 | `HostBuilder` 抛出则进程退出 |
| Phase 3 | 异步初始化OAuth 刷新、MCP 连接等) | `InitializeAsync` 可抛出 `AppInitializationException` |
| Phase 4 | 启动 REPL 或执行一次性 prompt | `RunAsync` 返回退出码 |
`reloadOnChange: true` 设置在 `~/.free-code/settings.json` 上,允许用户在 REPL 运行期间修改配置后立即生效,无需重启。
---
## 5.2 快速路径处理器
`QuickPathHandler` 对应原始 `cli.tsx` 中散布在文件顶部的 fast-path 条件判断。
**原始设计意图:** 原始代码在 Commander.js 解析之前,用一系列 `process.argv.includes()` 检查来处理特殊 flag避免触发 React/Ink 的初始化开销。.NET 版本将这些检查集中到一个静态类,并且同样在 DI 容器构建之前执行。
```csharp
public static class QuickPathHandler
{
public static bool TryHandle(string[] args, out int exitCode)
{
exitCode = 0;
// --version
if (args.Contains("--version"))
{
var ver = Assembly.GetEntryAssembly()!
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
.InformationalVersion;
Console.WriteLine($"free-code {ver}");
return true;
}
// --dump-system-prompt
if (args.Contains("--dump-system-prompt"))
{
var builder = new SystemPromptBuilder(/* minimal config */);
Console.WriteLine(builder.BuildDefaultPrompt());
return true;
}
// MCP daemon mode
if (args.Contains("--mcp-daemon"))
{
exitCode = McpDaemon.Run(args).GetAwaiter().GetResult();
return true;
}
// Bridge mode
if (args.Contains("--bridge"))
{
exitCode = BridgeMain.Run(args).GetAwaiter().GetResult();
return true;
}
// Background session mode
if (args.Contains("--background-session"))
{
exitCode = BackgroundSessionRunner.Run(args).GetAwaiter().GetResult();
return true;
}
// Template mode
if (args.Contains("--template"))
{
exitCode = TemplateRunner.Run(args).GetAwaiter().GetResult();
return true;
}
return false;
}
}
```
### 快速路径 flag 列表
| Flag | 处理器 | 说明 |
|------|--------|------|
| `--version` | 直接打印 | 从程序集元数据读取版本号 |
| `--dump-system-prompt` | `SystemPromptBuilder` | 打印默认 System Prompt用于调试 |
| `--mcp-daemon` | `McpDaemon.Run` | 以 MCP 服务器模式运行,供 IDE 插件调用 |
| `--bridge` | `BridgeMain.Run` | IDE 远程控制桥接模式 |
| `--background-session` | `BackgroundSessionRunner.Run` | 后台会话模式,供 Agent 工具启动子进程 |
| `--template` | `TemplateRunner.Run` | 模板执行模式 |
注意 `--mcp-daemon``--bridge` 的处理器使用 `.GetAwaiter().GetResult()` 同步等待,因为此时还没有 `async` 上下文可用(`Main``await` 路径已被 Phase 1 短路)。
---
## 5.3 CLI 命令定义 (CliCommandBuilder)
`CliCommandBuilder` 对应原始 `cli.tsx` 中的 Commander.js 配置,负责定义全局选项和根命令的处理逻辑。
**原始设计意图:** Commander.js 的 `program.option(...).action(...)` 链式 API 被映射到 `System.CommandLine``RootCommand` + `Option<T>` 模式。两者在概念上完全对应,只是 API 风格不同。
```csharp
public class CliCommandBuilder
{
public RootCommand Build()
{
var root = new RootCommand("free-code - The free build of Claude Code");
// 全局选项
var modelOption = new Option<string?>("--model", "Override default model");
var verboseOption = new Option<bool>("--verbose", "Verbose output");
var resumeOption = new Option<string?>("--resume", "Resume session ID");
root.AddGlobalOption(modelOption);
root.AddGlobalOption(verboseOption);
root.AddGlobalOption(resumeOption);
// 一次性 prompt 模式 (-p)
var promptOption = new Option<string?>("-p", "One-shot prompt (non-interactive)");
root.AddOption(promptOption);
root.SetHandler(async (string? prompt, string? model, bool verbose, string? resume) =>
{
if (prompt != null)
return await OneShotMode.ExecuteAsync(prompt, model);
return await REPLMode.StartAsync(model, verbose, resume);
}, promptOption, modelOption, verboseOption, resumeOption);
return root;
}
}
```
### 选项说明
| 选项 | 类型 | 作用域 | 说明 |
|------|------|--------|------|
| `--model` | `string?` | 全局 | 覆盖配置文件中的默认模型 |
| `--verbose` | `bool` | 全局 | 开启详细日志输出 |
| `--resume` | `string?` | 全局 | 恢复指定 session ID 的对话历史 |
| `-p` | `string?` | 根命令 | 非交互式一次性 prompt 模式 |
`--model``--verbose``--resume` 作为全局选项注册,子命令也可以访问。`-p` 仅在根命令级别有效,因为子命令有各自的参数结构。
`-p` 存在时,走 `OneShotMode.ExecuteAsync` 路径(非交互式,执行完毕后退出)。否则走 `REPLMode.StartAsync` 启动交互式终端 UI。
---
## 模块间依赖
```
Program.cs
├── QuickPathHandler (静态,无依赖)
│ ├── SystemPromptBuilder (仅 --dump-system-prompt 路径)
│ ├── McpDaemon
│ ├── BridgeMain
│ ├── BackgroundSessionRunner
│ └── TemplateRunner
└── CliCommandBuilder (由 IAppRunner 使用)
├── OneShotMode
└── REPLMode
```
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md)
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)

View File

@ -0,0 +1,191 @@
# 核心模块设计 — 命令系统
> 所属项目: free-code .NET 10 重写
> 原始代码来源: `../../src/commands.ts`, `../../src/commands/`
> 原始设计意图: 定义并注册所有斜杠命令(/command提供会话管理、配置变更、认证操作等用户可交互的命令集合
> 上级文档: [核心模块设计总览](核心模块设计.md)
---
## 概述
命令系统管理用户在 REPL 中输入的斜杠命令(如 `/clear``/model``/help`)。它与工具系统平行,但面向的是用户直接操作而非 Agent 的自主调用。
原始 TypeScript 实现在 `commands.ts` 中以数组形式注册约 80 个命令对象,每个对象实现固定的接口字段。.NET 重写引入 `ICommand` 接口和 `CommandRegistry` 注册表,通过 DI 容器管理命令生命周期,并支持基于认证状态和 feature flag 的动态可用性控制。
---
## 8.1 ICommand 接口
```csharp
public interface ICommand
{
string Name { get; }
string[]? Aliases { get; }
string Description { get; }
CommandCategory Category { get; } // Prompt, Local, LocalDialog
CommandAvailability Availability { get; } // Always, RequiresAuth, ClaudeAiOnly, InternalOnly
bool IsEnabled();
Task<CommandResult> ExecuteAsync(
CommandContext context, string? args = null, CancellationToken ct = default);
}
public enum CommandCategory { Prompt, Local, LocalDialog }
public enum CommandAvailability { Always, RequiresAuth, ClaudeAiOnly, InternalOnly }
public record CommandResult(bool Success, string? Output = null);
```
### 命令分类说明
`CommandCategory` 决定命令的执行方式:
| 类别 | 说明 |
|------|------|
| `Prompt` | 命令内容作为 prompt 发送给 LLM`/commit` 生成提交信息) |
| `Local` | 在本地直接执行,不经过 LLM`/clear` 清空历史) |
| `LocalDialog` | 在本地执行并打开对话框(如 `/config` 弹出配置界面) |
`CommandAvailability` 控制命令的可见性:
| 可用性 | 显示条件 |
|--------|---------|
| `Always` | 始终可用 |
| `RequiresAuth` | 需要已登录任意账号 |
| `ClaudeAiOnly` | 仅 Claude.ai 账号用户可见 |
| `InternalOnly` | 仅 Anthropic 内部用户可见 |
---
## 8.2 CommandRegistry 命令注册表
`CommandRegistry` 完整注册 70+ 个命令,并提供基于认证状态的过滤能力。
```csharp
public class CommandRegistry : ICommandRegistry
{
private readonly IServiceProvider _services;
private readonly IFeatureFlagService _features;
private readonly IAuthService _authService;
private List<ICommand>? _cachedCommands;
public async Task<IReadOnlyList<ICommand>> GetCommandsAsync()
{
if (_cachedCommands != null) return _cachedCommands;
_cachedCommands = new List<ICommand>();
// === 核心命令 (~65个) ===
// 会话管理
Add<SessionCommand>(); Add<ResumeCommand>(); Add<RenameCommand>();
Add<ClearCommand>(); Add<ExportCommand>();
// 配置
Add<ConfigCommand>(); Add<ModelCommand>(); Add<ThemeCommand>();
Add<ColorCommand>(); Add<OutputStyleCommand>(); Add<KeybindingsCommand>();
// 认证
Add<LoginCommand>(); Add<LogoutCommand>();
// 状态与统计
Add<StatusCommand>(); Add<CostCommand>(); Add<StatsCommand>(); Add<ExtraUsageCommand>();
// 工具与诊断
Add<DiffCommand>(); Add<CopyCommand>(); Add<DoctorCommand>();
Add<MemoryCommand>(); Add<AgentsCommand>();
// Git 集成
Add<CommitCommand>(); Add<CommitPushPrCommand>(); Add<BranchCommand>();
// MCP / LSP / Hooks
Add<McpCommand>(); Add<HooksCommand>(); Add<FilesCommand>();
// 扩展系统
Add<SkillsCommand>(); Add<PluginCommand>();
// 杂项
Add<HelpCommand>(); Add<ExitCommand>(); Add<VersionCommand>();
Add<AddDirCommand>(); Add<VimCommand>(); Add<FastCommand>();
Add<UpgradeCommand>(); Add<FeedbackCommand>(); Add<TagCommand>();
Add<CompactCommand>(); Add<ContextCommand>();
Add<PermissionsCommand>(); Add<EffortCommand>();
Add<TasksCommand>(); Add<TeleportCommand>(); Add<RewindCommand>();
Add<TerminalSetupCommand>(); Add<DesktopCommand>(); Add<MobileCommand>();
Add<ChromeCommand>(); Add<PrivacySettingsCommand>();
Add<AssistantCommand>(); Add<SandboxToggleCommand>();
Add<RemoteEnvCommand>(); Add<RateLimitOptionsCommand>();
Add<PassesCommand>(); Add<BreakCacheCommand>(); Add<SummaryCommand>();
Add<ShareCommand>(); Add<ReleaseNotesCommand>(); Add<StatusLineCommand>();
Add<PrCommentsCommand>(); Add<ReviewCommand>(); Add<UltrareviewCommand>();
Add<InstallGitHubAppCommand>(); Add<InstallSlackAppCommand>();
// === 条件命令 ===
if (_features.IsEnabled(FeatureFlags.Buddy))
Add<BtwCommand>();
if (_features.IsEnabled(FeatureFlags.Ultraplan))
Add<UltraplanCommand>();
Add<ThinkbackCommand>(); Add<ThinkbackPlayCommand>();
// === 内部命令 (~15个) ===
Add<HeapdumpCommand>(); Add<MockLimitsCommand>();
Add<BridgeKickCommand>(); Add<AntTraceCommand>();
Add<PerfIssueCommand>(); Add<DebugToolCallCommand>();
Add<OnboardingCommand>(); Add<BughunterCommand>();
Add<GoodClaudeCommand>(); Add<IssueCommand>();
Add<AdvisorCommand>(); Add<InsightsCommand>();
Add<ResetLimitsCommand>(); Add<CtxVizCommand>();
Add<OauthRefreshCommand>();
return _cachedCommands;
}
public async Task<IReadOnlyList<ICommand>> GetEnabledCommandsAsync()
{
var all = await GetCommandsAsync();
return all.Where(c => c.IsEnabled() && MeetsAvailability(c)).ToList();
}
private bool MeetsAvailability(ICommand cmd) => cmd.Availability switch
{
CommandAvailability.Always => true,
CommandAvailability.RequiresAuth => _authService.IsAuthenticated,
CommandAvailability.ClaudeAiOnly => _authService.IsClaudeAiUser,
CommandAvailability.InternalOnly => _authService.IsInternalUser,
_ => false
};
private void Add<T>() where T : ICommand =>
_cachedCommands!.Add(_services.GetRequiredService<T>());
}
```
---
## 8.3 命令分组统计
| 分组 | 命令数量 | 说明 |
|------|----------|------|
| 会话管理 | 5 | `session``resume``rename``clear``export` |
| 配置 | 6 | `config``model``theme``color``output-style``keybindings` |
| 认证 | 2 | `login``logout` |
| 状态与统计 | 4 | `status``cost``stats``extra-usage` |
| 工具与诊断 | 5 | `diff``copy``doctor``memory``agents` |
| Git 集成 | 3 | `commit``commit-push-pr``branch` |
| MCP/LSP/Hooks | 3 | `mcp``hooks``files` |
| 扩展系统 | 2 | `skills``plugin` |
| 杂项(核心) | ~30 | `help``exit``version``add-dir` 等 |
| 条件命令 | 2-4 | `btw`BUDDY`ultraplan`ULTRAPLAN`thinkback` |
| 内部命令 | 15 | `heapdump``mock-limits``bughunter` 等 |
---
## 8.4 与工具系统的区别
命令系统和工具系统都是 free-code 的扩展点,但职责不同:
| 维度 | 命令系统 | 工具系统 |
|------|----------|----------|
| 触发方 | 用户(输入 `/command` | Agent 自主决策 |
| 输入格式 | 斜杠 + 可选文本参数 | 结构化 JSONJSON Schema 验证) |
| 出现在 System Prompt | 是(命令描述段) | 是(工具描述段) |
| 权限控制粒度 | `CommandAvailability` 枚举 | `PermissionEngine` + `ToolPermissionContext` |
| 执行上下文 | `CommandContext` | `ToolExecutionContext` |
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [工具系统](核心模块设计-工具系统.md)
- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md)
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)

View File

@ -0,0 +1,301 @@
# 核心模块设计 — 多代理协调器 (Coordinator)
> 所属项目: free-code .NET 10 重写
> 文档类型: 核心模块设计
> 原始代码来源: `../../src/coordinator/``../../src/assistant/`
> 原始设计意图: 将单轮 LLM 交互扩展为多代理编排,负责 worker 生成、消息路由、团队管理与协调模式切换
> 上级文档: [核心模块设计总览](核心模块设计.md)
> 交叉参考: [工具系统](核心模块设计-工具系统.md) | [后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)
---
## 概述
多代理协调器是 free-code 中用于“把工作拆给多个 worker”的编排层。它不是简单的工具调用器而是面向复杂任务的调度中枢当用户问题可被拆分为研究、实现、验证等多个并行分支时协调器负责创建 worker、下发上下文、接收回传结果并在需要时继续同一 worker 的上下文。
原始源码中,`../../src/coordinator/coordinatorMode.ts` 承担了本模块的核心语义:定义 coordinator mode 的开关、worker 可用工具集合、系统提示词、session mode 匹配逻辑,以及 worker 结果的消息协议。`../../src/assistant/` 则提供与会话历史、会话筛选和辅助选择相关的配套能力,可视为协调器在“恢复旧会话”和“读取历史事件”场景下的支撑模块。
在 .NET 10 重写中,本模块应被抽象为独立服务层,避免与 QueryEngine、工具系统、后台任务管理直接耦合同时保留原有行为
- 允许 coordinator 生成 worker 并并发分发任务
- 允许 worker 通过消息队列继续执行
- 允许在会话恢复时自动匹配 coordinator/normal 模式
- 允许读取会话历史以重建协作上下文
---
## 设计职责
### 1. 多代理任务编排
协调器负责将一个高层目标拆解为多个子任务,并为每个子任务创建独立 worker。worker 适合执行以下类型工作:
- 代码库研究
- 定向实现
- 验证与测试
- 失败修复
### 2. worker 生命周期管理
协调器需掌握 worker 的创建、继续、停止与结果归档。worker 不是一次性调用;其价值在于保留已加载上下文后继续推进。
### 3. 代理间消息路由
worker 的结果以结构化任务通知返回,协调器再据此向用户总结,或把后续指令发回同一个 worker。
### 4. 团队协作管理
协调器支持 team create / delete / send-message 这类“代理团队”操作,用于将多个 worker 视为一个协作单元管理。
### 5. 会话模式同步
当用户恢复旧会话时,协调器需要根据保存的 session mode 自动切换当前环境变量,确保继续运行时处于正确的 coordinator/normal 模式。
---
## 原始实现要点
### `../../src/coordinator/coordinatorMode.ts`
该文件是本模块的语义核心,包含以下能力:
#### coordinator 模式开关
`isCoordinatorMode()` 通过 feature gate `COORDINATOR_MODE` 和环境变量 `CLAUDE_CODE_COORDINATOR_MODE` 判断当前是否进入协调模式。
#### 会话模式匹配
`matchSessionMode(sessionMode)` 会比较当前模式与已恢复会话记录的模式:
- 若一致,直接返回
- 若不一致,自动翻转环境变量
- 同时记录 analytics 事件
这说明 coordinator 是“会话状态敏感”的,而不是一个纯工具注册器。
#### worker 工具上下文
`getCoordinatorUserContext(mcpClients, scratchpadDir)` 会为 worker 注入可用工具说明:
- 简化模式下仅暴露 Bash / Read / Edit
- 常规模式下暴露 async agent 允许工具集
- 追加 MCP 服务名
- 在 scratchpad gate 开启时暴露 scratchpad 目录
#### coordinator 系统提示词
`getCoordinatorSystemPrompt()` 描述了 coordinator 的核心职责:
- 直接回答能答的问题
- 用 worker 处理适合并行化的工作
- 只能把新信息总结给用户,不能把 worker 当作对话对象
- 通过 `Agent``SendMessage``TaskStop` 管理 worker 生命周期
同时它还明确了 worker 结果的 `<task-notification>` 协议格式。
### `../../src/coordinator/workerAgent.ts`
当前快照中该文件为占位实现,但从模块命名可知,它原本承载 worker agent 的启动入口或 worker 代理适配层。在 .NET 重写里,这一职责应由 worker 启动器与任务执行器拆分承接。
### `../../src/assistant/*`
`../../src/assistant/sessionHistory.ts` 提供了会话事件分页读取:
- 先构造 OAuth 请求上下文
- 再按 `anchor_to_latest` / `before_id` 拉取分页事件
- 返回历史事件、游标与是否还有更旧内容
`sessionDiscovery.ts``gate.ts``index.ts``AssistantSessionChooser.tsx` 在本快照中多为占位或轻量 UI 适配,但它们体现出 assistant 模块的定位:用于发现、筛选、恢复与展示历史会话。
---
## .NET 10 接口设计
### ICoordinatorService
```csharp
public interface ICoordinatorService
{
bool IsCoordinatorMode { get; }
string? MatchSessionMode(SessionMode? sessionMode);
string BuildCoordinatorSystemPrompt(CoordinatorPromptContext context);
CoordinatorUserContext BuildWorkerContext(
IReadOnlyList<McpClientDescriptor> mcpClients,
string? scratchpadDirectory = null);
Task<WorkerHandle> SpawnWorkerAsync(
SpawnWorkerRequest request,
CancellationToken cancellationToken = default);
Task SendMessageAsync(
string workerId,
string message,
CancellationToken cancellationToken = default);
Task StopWorkerAsync(
string workerId,
CancellationToken cancellationToken = default);
Task<WorkerResult?> GetWorkerResultAsync(string workerId);
Task<TeamHandle> CreateTeamAsync(CreateTeamRequest request,
CancellationToken cancellationToken = default);
Task DeleteTeamAsync(string teamId,
CancellationToken cancellationToken = default);
Task SendTeamMessageAsync(string teamId, string message,
CancellationToken cancellationToken = default);
}
```
### IAgentWorker
```csharp
public interface IAgentWorker
{
string WorkerId { get; }
string Description { get; }
WorkerStatus Status { get; }
Task StartAsync(CancellationToken cancellationToken);
Task ContinueAsync(string message, CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
Task<WorkerSnapshot> CaptureSnapshotAsync();
}
```
### 支撑模型
```csharp
public enum SessionMode { Normal, Coordinator }
public sealed record CoordinatorPromptContext(
string WorkingDirectory,
string? ScratchpadDirectory,
IReadOnlyList<string> AllowedTools,
IReadOnlyList<string> McpServerNames);
public sealed record CoordinatorUserContext(
string WorkerToolsContext,
string SystemPrompt);
public sealed record SpawnWorkerRequest(
string Description,
string Prompt,
string WorkerType,
string? Model = null,
string? TeamId = null);
```
这些类型遵循 .NET 命名习惯,使用 PascalCase并把“提示词构建”和“worker 运行实例”分离,避免协调器直接依赖底层 LLM 调用细节。
---
## Worker 生成与生命周期
### 生成流程
1. 协调器接收高层目标
2. 判断是否适合并行拆分
3. 为每个分支构造自包含 prompt
4. 通过 worker 工厂创建 `IAgentWorker`
5. 记录 worker 到会话/任务存储
### 生命周期阶段
| 阶段 | 说明 |
|------|------|
| Created | worker 已创建但未启动 |
| Running | worker 正在执行 |
| Waiting | worker 暂停等待继续指令 |
| Completed | worker 已完成任务 |
| Failed | worker 执行失败 |
| Stopped | worker 被协调器主动停止 |
### 生命周期原则
- worker 应保留自己的上下文快照
- 继续 worker 时优先复用旧上下文,而不是重新启动新 worker
- 停止 worker 应保留最后状态,供后续诊断或继续执行
---
## 消息路由
### 结果回传协议
原始实现定义了 `<task-notification>` XML 作为 worker 结果消息格式。.NET 重写可保留相同语义,但建议使用结构化模型解析后再渲染给终端:
```csharp
public sealed record WorkerNotification(
string TaskId,
string Status,
string Summary,
string? Result,
WorkerUsage? Usage);
```
### 路由规则
- worker → coordinator上报任务完成、失败、暂停、进度
- coordinator → worker发送续作指令、修正指令、停止指令
- coordinator → 用户:仅输出总结,不暴露内部通知原文
### 设计约束
- 不能用一个 worker 检查另一个 worker
- 不能把低价值文件内容回读任务交给 worker
- 继续 worker 时必须使用其原始 task id
---
## Agent Team 管理
team 机制用于把多个 worker 归入一个协作单元,常见场景是“研究组 + 实现组 + 验证组”。
### TeamCreate
创建 team 时应生成团队 ID并维护成员 worker、目标、状态与消息历史。
### TeamDelete
删除 team 应清理团队元数据,但不能无条件销毁已完成 worker 的结果记录。
### SendMessage
team message 应广播到 team 成员,或按策略路由给主 worker。 .NET 设计应把广播规则显式化,避免隐式分支。
---
## 关联 `../../src/assistant/` 的 .NET 设计
`../../src/assistant/` 更适合映射为“会话访问与恢复辅助服务”,建议拆为:
- `IAssistantSessionHistoryService`:读取会话事件分页
- `IAssistantSessionDiscoveryService`:发现可恢复会话
- `IAssistantSessionChooser`:会话选择 UI 适配层
其中 `sessionHistory.ts` 的分页逻辑适合直接迁移为可测试的基础设施服务,而非放进协调器主服务中。
---
## 设计要点
- coordinator 是编排层,不是 worker 执行层
- worker 适合做局部自治coordinator 负责全局收敛
- 会话模式必须可恢复、可切换、可记录
- worker prompt 必须自包含,不能依赖隐式上下文
- 协调器输出面向用户,内部通知面向系统
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [工具系统](核心模块设计-工具系统.md)
- [后台任务管理](../基础设施设计/基础设施设计-后台任务管理.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)

View File

@ -0,0 +1,359 @@
# 核心模块设计 — 工具系统
> 所属项目: free-code .NET 10 重写
> 原始代码来源: `../../src/tools.ts`, `../../src/tools/`
> 原始设计意图: 定义 Agent 可调用工具的接口体系,提供工具基类复用逻辑,并统一管理内置工具与 MCP 工具的组装与去重
> 上级文档: [核心模块设计总览](核心模块设计.md)
> 交叉参考: [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md) | [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md)
---
## 概述
工具系统是 Agent 行为能力的载体。每一个工具对应 Agent 可以执行的一类操作,从执行 shell 命令(`BashTool`)到读写文件、搜索代码、访问网络,再到调用外部 MCP 服务。
原始 TypeScript 实现在 `tools.ts` 注册工具,在 `../../src/tools/` 目录下实现每个工具,工具本身是对象字面量或类实例。.NET 重写引入了完整的接口层次和抽象基类,使工具实现更标准化,并通过 DI 容器统一管理生命周期。
---
## 7.1 核心接口
### ITool — 工具基础接口
```csharp
/// <summary>Agent工具基础接口</summary>
public interface ITool
{
string Name { get; }
string[]? Aliases { get; }
string? SearchHint { get; }
ToolCategory Category { get; }
bool IsEnabled();
JsonElement GetInputSchema();
Task<string> GetDescriptionAsync(object? input = null);
bool IsConcurrencySafe(object input);
bool IsReadOnly(object input);
}
```
### ITool\<TInput, TOutput\> — 泛型工具接口
```csharp
/// <summary>泛型工具接口</summary>
public interface ITool<TInput, TOutput> : ITool where TInput : class
{
Task<ToolResult<TOutput>> ExecuteAsync(
TInput input, ToolExecutionContext context, CancellationToken ct = default);
Task<ValidationResult> ValidateInputAsync(TInput input);
Task<PermissionResult> CheckPermissionAsync(TInput input, ToolExecutionContext context);
}
```
### 支撑类型
```csharp
public record ToolResult<T>(
T Data,
bool IsError = false,
string? ErrorMessage = null,
List<Message>? SideMessages = null
);
public record ToolExecutionContext(
string WorkingDirectory,
PermissionMode PermissionMode,
IReadOnlyList<AdditionalWorkingDirectory> AdditionalWorkingDirectories,
IPermissionEngine PermissionEngine,
ILspClientManager LspManager,
IBackgroundTaskManager TaskManager,
IServiceProvider Services
);
public enum ToolCategory
{
FileSystem, Shell, Agent, Web, Lsp, Mcp,
UserInteraction, Todo, Task, PlanMode,
AgentSwarm, Worktree, Config
}
```
`ToolExecutionContext` 是执行时上下文,包含工具执行所需的所有环境信息。工具实现通过 `context` 参数访问当前工作目录、权限引擎、LSP 客户端等,而不是直接依赖全局状态。
---
## 7.2 工具基类 ToolBase\<TInput, TOutput\>
`ToolBase` 为工具实现提供默认行为,减少每个具体工具需要编写的样板代码。
**原始设计意图:** 原始 TypeScript 工具通过对象字面量共享一些约定,但没有强制继承关系。.NET 的抽象基类在编译期保证所有工具遵循统一接口,并为验证逻辑提供可选的 FluentValidation 集成点。
```csharp
public abstract class ToolBase<TInput, TOutput> : ITool<TInput, TOutput>
where TInput : class
{
public abstract string Name { get; }
public virtual string[]? Aliases => null;
public virtual string? SearchHint => null;
public abstract ToolCategory Category { get; }
public virtual bool IsEnabled() => true;
public abstract JsonElement GetInputSchema();
public virtual Task<string> GetDescriptionAsync(object? input = null)
=> Task.FromResult($"Execute {Name}");
public abstract bool IsConcurrencySafe(TInput input);
public abstract bool IsReadOnly(TInput input);
public abstract Task<ToolResult<TOutput>> ExecuteAsync(
TInput input, ToolExecutionContext context, CancellationToken ct);
public virtual Task<ValidationResult> ValidateInputAsync(TInput input)
{
var validator = GetValidator();
if (validator != null)
{
var result = validator.Validate(input);
return Task.FromResult(result.IsValid
? ValidationResult.Success()
: ValidationResult.Failure(result.Errors.Select(e => e.ErrorMessage)));
}
return Task.FromResult(ValidationResult.Success());
}
public virtual Task<PermissionResult> CheckPermissionAsync(
TInput input, ToolExecutionContext context)
=> Task.FromResult(PermissionResult.Allowed());
protected virtual IValidator<TInput>? GetValidator() => null;
}
```
子类只需实现 `Name``Category``GetInputSchema``IsConcurrencySafe``IsReadOnly``ExecuteAsync`。验证逻辑通过重写 `GetValidator()` 返回一个 FluentValidation 的 `IValidator<TInput>` 实例来接入。
---
## 7.3 BashTool 完整实现
`BashTool` 是最核心也最复杂的工具,展示了工具系统的完整实现模式。
**原始设计意图:** 原始 `BashTool.tsx` 使用 Node.js 的 `child_process.spawn` 执行命令,通过 Promise 管理超时,并通过 `IBackgroundTaskManager` 支持后台执行。.NET 版本使用 `Process``WaitForExitAsync` + `.WaitAsync(timeout)` 实现相同语义。
```csharp
public class BashTool : ToolBase<BashToolInput, BashToolOutput>
{
public override string Name => "Bash";
public override ToolCategory Category => ToolCategory.Shell;
private readonly IBackgroundTaskManager _taskManager;
private readonly IFeatureFlagService _features;
public override async Task<ToolResult<BashToolOutput>> ExecuteAsync(
BashToolInput input, ToolExecutionContext context, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = $"-c \"{input.Command.Replace("\"", "\\\"")}\"",
WorkingDirectory = context.WorkingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
// 沙箱
if (!input.DangerouslyDisableSandbox)
ApplySandboxRestrictions(psi, context);
// 后台执行
if (input.RunInBackground)
return await RunInBackgroundAsync(input, psi, ct);
// 前台执行
using var process = new Process { StartInfo = psi };
process.Start();
var timeoutMs = input.Timeout ?? 120_000;
try
{
await process.WaitForExitAsync(ct)
.WaitAsync(TimeSpan.FromMilliseconds(timeoutMs), ct);
}
catch (TimeoutException)
{
process.Kill(entireProcessTree: true);
return new ToolResult<BashToolOutput>(new BashToolOutput
{
Stdout = await process.StandardOutput.ReadToEndAsync(ct),
Stderr = await process.StandardError.ReadToEndAsync(ct),
ExitCode = -1,
Interrupted = true
});
}
var stdout = await process.StandardOutput.ReadToEndAsync(ct);
var stderr = await process.StandardError.ReadToEndAsync(ct);
return new ToolResult<BashToolOutput>(new BashToolOutput
{
Stdout = stdout, Stderr = stderr,
ExitCode = process.ExitCode, Interrupted = false
});
}
private async Task<ToolResult<BashToolOutput>> RunInBackgroundAsync(
BashToolInput input, ProcessStartInfo psi, CancellationToken ct)
{
var task = await _taskManager.CreateShellTaskAsync(input.Command, psi);
return new ToolResult<BashToolOutput>(new BashToolOutput
{
Stdout = $"Background task started: {task.TaskId}",
BackgroundTaskId = task.TaskId
});
}
public override bool IsConcurrencySafe(BashToolInput input) => false;
public override bool IsReadOnly(BashToolInput input) =>
CommandClassifier.IsReadCommand(input.Command);
}
```
### BashTool 输入/输出类型
```csharp
public record BashToolInput
{
public string Command { get; init; } = "";
public int? Timeout { get; init; }
public string? Description { get; init; }
public bool RunInBackground { get; init; }
public bool DangerouslyDisableSandbox { get; init; }
}
public record BashToolOutput
{
public string Stdout { get; init; } = "";
public string Stderr { get; init; } = "";
public int ExitCode { get; init; }
public bool Interrupted { get; init; }
public string? BackgroundTaskId { get; init; }
}
```
`IsConcurrencySafe` 返回 `false` 表示 Bash 命令不能并发执行(防止状态污染)。`IsReadOnly` 委托给 `CommandClassifier.IsReadCommand`,该工具通过命令前缀分析判断是否为只读操作(如 `ls``cat``grep` 等),只读命令在某些权限模式下可以跳过确认。
---
## 7.4 ToolRegistry — 工具池组装与 MCP 去重
`ToolRegistry` 负责将内置工具和 MCP 工具组装为统一的工具池,并应用去重和权限过滤规则。
```csharp
public class ToolRegistry : IToolRegistry
{
private readonly IServiceProvider _services;
private readonly IFeatureFlagService _features;
private readonly IMcpClientManager _mcpManager;
private List<ITool>? _cachedBaseTools;
public async Task<IReadOnlyList<ITool>> GetToolsAsync(
ToolPermissionContext? permissionContext = null)
{
var baseTools = GetBaseTools();
var mcpTools = await _mcpManager.GetToolsAsync();
var pool = AssembleToolPool(baseTools, mcpTools);
if (permissionContext != null)
pool = FilterByDenyRules(pool, permissionContext);
return pool;
}
private List<ITool> GetBaseTools()
{
if (_cachedBaseTools != null) return _cachedBaseTools;
_cachedBaseTools = new List<ITool>();
// 核心20个工具 (始终可用)
_cachedBaseTools.AddRange(new ITool[] {
Get<FileReadTool>(), Get<FileEditTool>(), Get<FileWriteTool>(),
Get<GlobTool>(), Get<GrepTool>(), Get<BashTool>(),
Get<AgentTool>(), Get<SkillTool>(), Get<TaskOutputTool>(),
Get<WebFetchTool>(), Get<WebSearchTool>(), Get<LspTool>(),
Get<TodoWriteTool>(), Get<AskUserQuestionTool>(), Get<BriefTool>(),
Get<ListMcpResourcesTool>(), Get<ReadMcpResourceTool>(),
Get<ToolSearchTool>(), Get<ConfigTool>(), Get<NotebookEditTool>(),
});
// 条件工具 (feature-flagged)
if (_features.IsEnabled(FeatureFlags.AgentTriggers))
{
_cachedBaseTools.Add(Get<CronTool>());
_cachedBaseTools.Add(Get<MonitorTool>());
}
if (_features.IsEnabled(FeatureFlags.V2Todo))
{
_cachedBaseTools.AddRange(new ITool[] {
Get<TaskCreateTool>(), Get<TaskGetTool>(), Get<TaskUpdateTool>(),
Get<TaskListTool>(), Get<TaskStopTool>(),
});
}
// Swarm + PlanMode + Worktree (始终注册)
_cachedBaseTools.AddRange(new ITool[] {
Get<SendMessageTool>(), Get<TeamCreateTool>(), Get<TeamDeleteTool>(),
Get<EnterPlanModeTool>(), Get<ExitPlanModeTool>(),
Get<EnterWorktreeTool>(), Get<ExitWorktreeTool>(),
});
// 稳定排序 (prompt cache一致性)
_cachedBaseTools.Sort((a, b) =>
string.Compare(a.Name, b.Name, StringComparison.Ordinal));
return _cachedBaseTools;
}
/// <summary>组装工具池: 内置优先, MCP去重</summary>
private List<ITool> AssembleToolPool(
List<ITool> baseTools, IReadOnlyList<ITool> mcpTools)
{
var pool = new List<ITool>(baseTools);
var baseNames = baseTools.Select(t => t.Name).ToHashSet();
foreach (var mcpTool in mcpTools)
{
if (!baseNames.Contains(mcpTool.Name))
pool.Add(mcpTool);
}
return pool;
}
private T Get<T>() where T : ITool => _services.GetRequiredService<T>();
}
```
### 工具分类
| 类别 | 数量 | 说明 |
|------|------|------|
| 核心工具 | 20 个 | 始终可用,不受 feature flag 影响 |
| 条件工具 | 7 个 | 由 `AgentTriggers``V2Todo` 等 feature flag 控制 |
| Swarm/Plan/Worktree | 7 个 | 始终注册,但某些工具在特定模式外会返回错误 |
| MCP 工具 | 动态 | 从已连接的 MCP 服务器获取,同名工具被内置工具覆盖 |
### 去重策略说明
内置工具名称集合构建为 `HashSet<string>`MCP 工具逐一检查。若名称已存在于集合中,则该 MCP 工具被静默跳过。这一策略确保:
1. 外部 MCP 服务器无法意外替换 `Bash``FileRead` 等核心工具
2. 用户可以通过 MCP 扩展新工具,但不能降低现有工具的可靠性
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md)
- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md)
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)

View File

@ -0,0 +1,301 @@
# 核心模块设计 — 查询引擎 (QueryEngine)
> 所属项目: free-code .NET 10 重写
> 原始代码来源: `../../src/QueryEngine.ts`
> 原始设计意图: 协调 LLM 消息流转、工具调用循环和 System Prompt 构建,是整个 Agent 行为的核心管道
> 上级文档: [核心模块设计总览](核心模块设计.md)
> 交叉参考: [CLI 启动与解析](核心模块设计-CLI启动与解析.md) | [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md)
---
## 概述
`QueryEngine` 是 free-code 的核心消息处理管道。每当用户提交一条消息,`IQueryEngine.SubmitMessageAsync` 便接管控制权,驱动一个"助手回复 → 工具调用 → 执行结果 → 继续"的循环,直到助手不再请求工具调用或用户取消。
原始 `QueryEngine.ts` 是一个约 1200 行的 TypeScript 文件包含消息历史管理、System Prompt 构建、工具执行调度、上下文压缩判断、记忆提取等所有逻辑。.NET 重写将其中的 System Prompt 构建拆分为独立的 `SystemPromptBuilder`(实现 `IPromptBuilder`),其余核心循环保留在 `QueryEngine` 中。
---
## 6.1 IQueryEngine 接口
```csharp
/// <summary>
/// LLM查询引擎 - 核心消息处理管道
/// 对应原始 ../../src/QueryEngine.ts
/// </summary>
public interface IQueryEngine
{
/// <summary>提交用户消息并返回流式响应</summary>
IAsyncEnumerable<SDKMessage> SubmitMessageAsync(
string content,
SubmitMessageOptions? options = null,
CancellationToken ct = default);
/// <summary>取消当前查询</summary>
Task CancelAsync();
/// <summary>获取消息历史</summary>
IReadOnlyList<Message> GetMessages();
/// <summary>获取当前token使用量</summary>
TokenUsage GetCurrentUsage();
}
public record SubmitMessageOptions(
string? Model = null,
ToolPermissionContext? PermissionContext = null,
string? QuerySource = null,
bool IsSpeculation = false
);
```
`IAsyncEnumerable<SDKMessage>` 返回类型让调用方(终端 UI 层)可以在消息流抵达时即时渲染,无需等待整个响应完成。`SDKMessage` 是一个联合类型discriminated union涵盖流式文本增量、工具调用开始、工具执行结果等所有消息类型。
---
## 6.2 QueryEngine 实现
### 依赖关系
`QueryEngine` 通过构造函数注入以下依赖,每一项都有对应的原始 TypeScript 概念:
| .NET 依赖 | 对应原始概念 |
|-----------|-------------|
| `IApiProviderRouter` | `claude.ts` 的 API 调用函数 |
| `IToolRegistry` | `tools.ts``getTools()` |
| `IPermissionEngine` | `permission.ts` 的权限检查逻辑 |
| `IPromptBuilder` | `getSystemPrompt()` 内联函数 |
| `ISessionMemoryService` | `memory.ts` 相关逻辑 |
| `IFeatureFlagService` | `isFeatureEnabled()` |
### 流式循环实现
```csharp
public class QueryEngine : IQueryEngine
{
private readonly IApiProviderRouter _providerRouter;
private readonly IToolRegistry _toolRegistry;
private readonly IPermissionEngine _permissionEngine;
private readonly IPromptBuilder _promptBuilder;
private readonly ISessionMemoryService _memoryService;
private readonly IFeatureFlagService _featureFlags;
private readonly ILogger<QueryEngine> _logger;
private readonly List<Message> _messages = new();
private CancellationTokenSource? _activeCts;
public async IAsyncEnumerable<SDKMessage> SubmitMessageAsync(
string content,
SubmitMessageOptions? options = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
_activeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
options ??= new SubmitMessageOptions();
// 1. 构建用户消息
var userMessage = new Message
{
Role = MessageRole.User,
Content = new UserContent(content),
Timestamp = DateTime.UtcNow,
MessageId = Guid.NewGuid().ToString()
};
_messages.Add(userMessage);
yield return new SDKMessage.UserMessage(userMessage);
// 2. 查询循环 (Assistant → ToolUse → Execute → Continue)
var shouldContinue = true;
while (shouldContinue && !_activeCts.Token.IsCancellationRequested)
{
// 2a. 构建System Prompt
var systemPrompt = await _promptBuilder.BuildAsync(
_messages, options.PermissionContext, options);
// 2b. 获取可用工具
var tools = await _toolRegistry.GetToolsAsync(options.PermissionContext);
// 2c. 调用API Provider
var provider = _providerRouter.GetActiveProvider();
var apiRequest = new ApiRequest
{
SystemPrompt = systemPrompt,
Messages = BuildApiMessages(_messages),
Tools = tools,
Model = options.Model
};
await foreach (var response in provider.StreamAsync(
apiRequest, _activeCts.Token))
{
switch (response)
{
case SDKMessage.AssistantMessage am:
_messages.Add(am.ToMessage());
yield return am;
break;
case SDKMessage.StreamingDelta sd:
yield return sd;
break;
case SDKMessage.ToolUseStart tus:
yield return tus;
break;
case SDKMessage.ToolUseResult tur:
shouldContinue = tur.ShouldContinue;
_messages.Add(tur.ToMessage());
yield return tur;
break;
case SDKMessage.CompactBoundary cb:
yield return cb;
break;
}
}
}
// 3. 后处理
await PostQueryProcessingAsync(options);
}
private async Task PostQueryProcessingAsync(SubmitMessageOptions options)
{
// 记忆提取 (EXTRACT_MEMORIES flag)
if (_featureFlags.IsEnabled(FeatureFlags.ExtractMemories))
{
_ = _memoryService.TryExtractAsync(_messages); // fire-and-forget
}
// Prompt建议
// 上下文压缩检查
// 统计更新
}
public Task CancelAsync()
{
_activeCts?.Cancel();
return Task.CompletedTask;
}
public IReadOnlyList<Message> GetMessages() => _messages.AsReadOnly();
public TokenUsage GetCurrentUsage() => /* 从最近API响应头提取 */;
}
```
### 循环流程图
```
用户提交消息
构建 System Prompt ◄────────────────────┐
│ │
▼ │
获取工具列表 │
│ │
▼ │
调用 API Provider (流式) │
│ │
├── AssistantMessage → 存入历史 │
├── StreamingDelta → 直接 yield │
├── ToolUseStart → 直接 yield │
└── ToolUseResult ────────────────────┤
│ ShouldContinue = true │
└────────────────────────────►┘
│ ShouldContinue = false
后处理(记忆提取等)
结束
```
---
## 6.3 System Prompt Builder
`SystemPromptBuilder` 对应原始 `QueryEngine.ts` 中内联的 `getSystemPrompt()` 函数,负责将多个信息源组装为最终发送给 API 的 System Prompt。
**原始设计意图:** 原始代码将系统指令、工具描述、命令描述、记忆、上下文信息拼接在一个函数中,每次查询都重新计算。.NET 重写将其提取为独立接口 `IPromptBuilder`,便于测试和替换。
```csharp
public class SystemPromptBuilder : IPromptBuilder
{
private readonly IToolRegistry _toolRegistry;
private readonly ICommandRegistry _commandRegistry;
private readonly ISessionMemoryService _memoryService;
private readonly IFeatureFlagService _features;
private readonly ICompanionService? _companionService;
public async Task<string> BuildAsync(
IReadOnlyList<Message> messages,
ToolPermissionContext? permissionContext,
SubmitMessageOptions options)
{
var sb = new StringBuilder();
// 1. 基础系统指令 (CLAUDE.md 等价物)
sb.AppendLine(await BuildBaseInstructionsAsync());
// 2. 工具描述 (JSON Schema)
var tools = await _toolRegistry.GetToolsAsync(permissionContext);
sb.AppendLine(BuildToolDescriptions(tools));
// 3. 命令描述
var commands = await _commandRegistry.GetEnabledCommandsAsync();
sb.AppendLine(BuildCommandDescriptions(commands));
// 4. 会话记忆
var memory = await _memoryService.GetCurrentMemoryAsync();
if (memory != null)
sb.AppendLine($"<session_memory>\n{memory.Content}\n</session_memory>");
// 5. 上下文信息 (工作目录、Git状态、环境)
sb.AppendLine(await BuildContextInfoAsync());
// 6. 同伴介绍 (BUDDY flag)
if (_features.IsEnabled(FeatureFlags.Buddy) && _companionService != null)
{
var companion = await _companionService.GetCompanionAsync();
if (companion != null)
sb.AppendLine(CompanionPromptBuilder.BuildIntro(companion));
}
return sb.ToString();
}
}
```
### 六段构建逻辑说明
| 段落 | 内容来源 | 条件 |
|------|----------|------|
| 1. 基础指令 | `BuildBaseInstructionsAsync()` — 读取内置指令文本 | 始终包含 |
| 2. 工具描述 | `IToolRegistry.GetToolsAsync()` — 当前可用工具的 JSON Schema | 始终包含 |
| 3. 命令描述 | `ICommandRegistry.GetEnabledCommandsAsync()` — 斜杠命令列表 | 始终包含 |
| 4. 会话记忆 | `ISessionMemoryService.GetCurrentMemoryAsync()` | 存在记忆时包含 |
| 5. 上下文信息 | 工作目录、Git 状态、环境变量摘要 | 始终包含 |
| 6. 同伴介绍 | `ICompanionService.GetCompanionAsync()` | `BUDDY` feature flag 开启时包含 |
工具描述(段落 2的稳定性直接影响 Anthropic 的 prompt cache 命中率。`ToolRegistry` 对工具列表进行字典序排序(见[工具系统](核心模块设计-工具系统.md))正是为了保证这一段的内容在相同工具集下保持不变。
---
## 取消机制
`CancelAsync()` 通过 `CancellationTokenSource.Cancel()` 触发取消。`SubmitMessageAsync` 内的 `[EnumeratorCancellation]` 特性确保当外部 `CancellationToken` 被取消时,异步枚举自动停止。
两个取消来源通过 `CreateLinkedTokenSource` 合并:
- 用户调用 `CancelAsync()`(例如按 Ctrl+C
- 外部传入的 `ct` 参数(例如超时或应用关闭)
---
## 参考资料
- [核心模块设计总览](核心模块设计.md)
- [CLI 启动与解析](核心模块设计-CLI启动与解析.md)
- [工具系统](核心模块设计-工具系统.md)
- [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md)
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)

View File

@ -0,0 +1,118 @@
# 核心模块设计 — 总览
> 所属项目: free-code .NET 10 重写
> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [基础设施设计](../基础设施设计/) | [服务子系统设计](../服务子系统设计/) | [参考映射](reference/原始代码映射-核心模块.md)
---
## 概述
核心模块是整个 free-code .NET 10 重写的骨架,直接对应原始 TypeScript 项目中最关键的几个文件。本组文档覆盖从进程启动到 LLM 调用的完整主路径,包含五个相互协作的子系统。
原始 TypeScript 实现将大量逻辑集中在少数几个大文件里(`cli.tsx``QueryEngine.ts``tools.ts``commands.ts`)。.NET 重写将这些职责拆分为更清晰的接口、抽象类和注册表,并通过依赖注入在运行时组装。
---
## 架构概览
```
Program.cs (入口)
├── QuickPathHandler 快速路径:零 DI 开销,直接处理 flag 参数
└── IAppRunner (REPL/OnShot)
└── IQueryEngine 消息处理主管道
├── IApiProviderRouter → 路由到 Anthropic / Bedrock / Vertex / Codex / Foundry
├── IToolRegistry → 核心工具 + MCP 工具去重组装
├── ICommandRegistry → 70+ 斜杠命令
└── IPromptBuilder → 6 段式 System Prompt 构建
```
一次用户提交的消息从 `IQueryEngine.SubmitMessageAsync` 进入,经历以下流程:
1. 构建 System Prompt`SystemPromptBuilder`
2. 获取可用工具列表(`ToolRegistry`
3. 通过 `ApiProviderRouter` 选择活跃的 API 提供商并发起流式请求
4. 在 `while` 循环中处理响应流,直到模型不再触发工具调用
5. 后处理:记忆提取、上下文压缩检查、统计更新
---
## 子模块列表
### [CLI 启动与解析](核心模块设计-CLI启动与解析.md)
覆盖 `Program.cs` 的四阶段启动流程、`QuickPathHandler` 快速路径处理,以及基于 `System.CommandLine` 的 CLI 命令定义。
原始来源: `../../src/entrypoints/cli.tsx`, `../../src/screens/REPL.tsx`
---
### [查询引擎 (QueryEngine)](核心模块设计-查询引擎-QueryEngine.md)
覆盖 `IQueryEngine` 接口、`QueryEngine` 流式循环实现,以及 `SystemPromptBuilder` 的六段构建逻辑。
原始来源: `../../src/QueryEngine.ts`
---
### [工具系统](核心模块设计-工具系统.md)
覆盖 `ITool` / `ITool<TInput,TOutput>` 接口体系、`ToolBase<TInput,TOutput>` 抽象基类、`BashTool` 完整实现,以及 `ToolRegistry` 组装与 MCP 去重策略。
原始来源: `../../src/tools.ts`, `../../src/tools/`
---
### [多代理协调器 (Coordinator)](核心模块设计-多代理协调.md)
覆盖 coordinator mode 的模式切换、worker 启停与继续、团队消息路由、会话恢复匹配,以及 `../../src/assistant/` 的历史会话读取配套能力。
原始来源: `../../src/coordinator/`, `../../src/assistant/`
---
### [命令系统](核心模块设计-命令系统.md)
覆盖 `ICommand` 接口、`CommandRegistry` 注册 70+ 斜杠命令,以及命令分类与可用性控制机制。
原始来源: `../../src/commands.ts`, `../../src/commands/`
---
### [API 多提供商路由](核心模块设计-API提供商路由.md)
覆盖 `ApiProviderRouter` 的环境变量自动检测、`AnthropicProvider` SSE 流式解析实现,以及全部提供商枚举。
原始来源: `../../src/services/api/`
---
## 关键设计决策
**依赖注入替代全局单例**
原始 TypeScript 代码广泛使用模块级全局变量共享状态。.NET 重写通过 `IServiceProvider` 和构造函数注入统一管理所有依赖,生命周期由 `AddCoreServices()` / `AddEngine()` 等扩展方法集中配置。
**快速路径零开销**
`QuickPathHandler.TryHandle` 在 DI 容器构建之前执行,确保 `--version``--mcp-daemon` 等场景不承担任何初始化开销。
**工具池稳定排序**
`ToolRegistry` 对基础工具列表按名称字典序排序,保证在相同 feature flag 组合下 System Prompt 中的工具顺序不变,从而最大化 Anthropic prompt cache 命中率。
**MCP 工具去重策略**
内置工具的名称集合作为 `HashSet` 优先占位MCP 同名工具被静默丢弃,防止外部 MCP 服务器意外覆盖核心工具行为。
---
## 参考资料
- [原始代码映射 — 核心模块](reference/原始代码映射-核心模块.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)
- [基础设施设计 — 状态管理](../基础设施设计/基础设施设计-状态管理.md)
- [基础设施设计 — MCP 协议集成](../基础设施设计/基础设施设计-MCP协议集成.md)

View File

@ -0,0 +1,116 @@
# 原始代码映射 — 测试与构建
> 所属项目: free-code .NET 10 重写
> 文档类型: 参考映射表
> 上级文档: [测试与构建总览](../测试与构建.md)
---
## 说明
本文档建立 .NET 10 测试类型和构建配置与原始 TypeScript 源文件之间的映射关系。测试层是 .NET 重写新增的内容(原始项目无系统化测试),因此大部分映射说明的是"覆盖哪个原始模块的逻辑"而不是"重写了哪个测试文件"。构建部分则记录了从 Bun 脚本到 MSBuild/dotnet publish 的对应关系。
---
## 测试覆盖映射
### 单元测试 — FreeCode.Tests.Unit
| 测试类 | 测试项目路径 | 覆盖原始 TS 文件 | 测试重点 |
|--------|------------|----------------|---------|
| `BashToolTests` | `Tests/Tools/BashToolTests.cs` | `../../../src/tools/BashTool.tsx` | 命令执行、超时中断、后台任务 ID、只读分类 |
| `FileReadToolTests` | `Tests/Tools/FileReadToolTests.cs` | `../../../src/tools/FileReadTool.tsx` | 路径验证、编码处理、行范围切片 |
| `FileEditToolTests` | `Tests/Tools/FileEditToolTests.cs` | `../../../src/tools/FileEditTool.tsx` | 字符串替换、多匹配报错、文件不存在报错 |
| `FileWriteToolTests` | `Tests/Tools/FileWriteToolTests.cs` | `../../../src/tools/FileWriteTool.tsx` | 新建文件、覆盖已有文件、父目录不存在 |
| `GlobToolTests` | `Tests/Tools/GlobToolTests.cs` | `../../../src/tools/GlobTool.tsx` | 模式匹配、路径限制、结果排序 |
| `GrepToolTests` | `Tests/Tools/GrepToolTests.cs` | `../../../src/tools/GrepTool.tsx` | 正则搜索、include 过滤、上下文行 |
| `AgentToolTests` | `Tests/Tools/AgentToolTests.cs` | `../../../src/tools/AgentTool.tsx` | 子代理启动、结果收集、嵌套深度限制 |
| `ToolRegistryTests` | `Tests/Tools/ToolRegistryTests.cs` | `../../../src/tools.ts` | 基础工具注册、MCP 工具去重、稳定排序 |
| `QueryEngineTests` | `Tests/Engine/QueryEngineTests.cs` | `../../../src/QueryEngine.ts` | 消息循环、工具调用轮次、流式输出 |
| `SystemPromptBuilderTests` | `Tests/Engine/SystemPromptBuilderTests.cs` | `../../../src/QueryEngine.ts`(内联 `getSystemPrompt`| 六段构建顺序、feature flag 条件段 |
| `CommandRegistryTests` | `Tests/Commands/CommandRegistryTests.cs` | `../../../src/commands.ts` | 命令注册、可用性过滤、认证状态过滤 |
| `AnthropicProviderTests` | `Tests/ApiProviders/AnthropicProviderTests.cs` | `../../../src/services/api/claude.ts` | SSE 解析、事件类型映射、错误处理 |
| `CodexProviderTests` | `Tests/ApiProviders/CodexProviderTests.cs` | `../../../src/services/api/`Codex adapter| 消息格式转换、流式响应 |
| `ProviderRouterTests` | `Tests/ApiProviders/ProviderRouterTests.cs` | `../../../src/services/api/`env 判断逻辑)| 环境变量路由、默认提供商 |
| `McpClientTests` | `Tests/Mcp/McpClientTests.cs` | `../../../src/services/mcp/` | JSON-RPC 请求/响应、工具调用 |
| `McpClientManagerTests` | `Tests/Mcp/McpClientManagerTests.cs` | `../../../src/services/mcp/` | 多服务器管理、断线重连 |
| `TransportTests` | `Tests/Mcp/TransportTests.cs` | `../../../src/services/mcp/`(传输层)| stdio 传输、SSE 传输 |
| `LspClientManagerTests` | `Tests/Lsp/LspClientManagerTests.cs` | `../../../src/services/lsp/` | 服务器实例管理、LSP 协议握手 |
| `AuthServiceTests` | `Tests/Services/AuthServiceTests.cs` | `../../../src/services/oauth/` | Token 缓存、刷新逻辑 |
| `RateLimitServiceTests` | `Tests/Services/RateLimitServiceTests.cs` | `../../../src/services/` | 令牌桶算法、429 处理 |
| `SessionMemoryServiceTests` | `Tests/Services/SessionMemoryServiceTests.cs` | `../../../src/services/sessionMemory/` | 记忆读写、过期淘汰 |
| `CompanionServiceTests` | `Tests/Services/CompanionServiceTests.cs` | 无直接对应(新增 Companion 功能)| 骰子确定性、用户隔离 |
| `NotificationServiceTests` | `Tests/Services/NotificationServiceTests.cs` | `../../../src/services/` | 通知订阅、事件分发 |
| `AppStateStoreTests` | `Tests/State/AppStateStoreTests.cs` | `../../../src/state/` | 状态不可变性、订阅通知、并发安全 |
---
### 集成测试 — FreeCode.Tests.Integration
| 测试类 | 测试项目路径 | 覆盖场景 | 涉及原始模块 |
|--------|------------|---------|------------|
| `QueryPipelineTests` | `QueryPipelineTests.cs` | 完整查询管道QueryEngine + Provider + ToolRegistry | `../../../src/QueryEngine.ts` + `../../../src/tools.ts` |
| `McpIntegrationTests` | `McpIntegrationTests.cs` | 真实 MCP stdio 服务器连接和工具调用 | `../../../src/services/mcp/` |
| `LspIntegrationTests` | `LspIntegrationTests.cs` | LSP 服务器启动、诊断获取 | `../../../src/services/lsp/` |
| `BridgeIntegrationTests` | `BridgeIntegrationTests.cs` | IDE Bridge 握手和命令分发 | `../../../src/bridge/` |
| `TaskManagerTests` | `TaskManagerTests.cs` | 后台任务创建、状态更新、取消 | `../../../src/tasks/` |
| `PluginLoadingTests` | `PluginLoadingTests.cs` | AssemblyLoadContext 加载卸载插件 | `../../../src/plugins/` |
---
### E2E 测试 — FreeCode.Tests.E2E
| 测试类 | 测试项目路径 | 覆盖场景 | 涉及原始模块 |
|--------|------------|---------|------------|
| `CliTests` | `CliTests.cs` | `--version``--help` 标志输出 | `../../../src/entrypoints/cli.tsx` |
| `OneShotModeTests` | `OneShotModeTests.cs` | `-p "prompt"` 一次性模式完整流程 | `../../../src/entrypoints/cli.tsx` |
| `InteractiveReplTests` | `InteractiveReplTests.cs` | 伪终端输入输出验证 | `../../../src/screens/REPL.tsx` |
---
## 构建映射
### 构建脚本对应
| .NET 构建文件 | 原始 TS 文件 | 功能对应 | 关键差异 |
|--------------|------------|---------|---------|
| `Directory.Build.props` | 无直接对应 | 全局项目属性 | MSBuild 中心化配置Bun 项目无此概念 |
| `build.sh` | `scripts/build.ts` | 多平台批量构建 | Bun 打包 JS bundle`dotnet publish` 输出 AOT 原生二进制 |
| `install.sh` | `install.sh`(原始)| 一键安装 | 检测逻辑相同;目标文件从 Bun bundle 改为 AOT 二进制 |
| `FreeCode.csproj` | `package.json` | 项目依赖和元数据 | 明确引用各子项目,取代 npm 依赖树 |
---
### 发布目标对应
| .NET RID | 原始 Bun 平台 | 说明 |
|----------|-------------|------|
| `osx-arm64` | Darwin arm64 | Apple Silicon Mac与原始 `install.sh``Darwin-arm64` 分支对应 |
| `osx-x64` | Darwin x86_64 | Intel Mac与原始 `Darwin-x86_64` 分支对应 |
| `linux-x64` | Linux x86_64 | 主流 Linux 服务器,与原始 `Linux-x86_64` 分支对应 |
| `linux-arm64` | Linux aarch64 | 与原始 `Linux-aarch64` 分支对应 |
| `win-x64` | 无(原始不支持 Windows| .NET 重写新增的目标平台 |
---
## 未直接映射的原始文件
以下原始文件无对应测试文件(原始项目没有测试),相关逻辑的验证由集成测试和 E2E 测试覆盖:
| 原始文件 | 验证方式 |
|----------|---------|
| `../../../src/entrypoints/cli.tsx` | `CliTests.cs``OneShotModeTests.cs` E2E 验证 |
| `../../../src/screens/REPL.tsx` | `InteractiveReplTests.cs` E2E 验证 |
| `../../../src/components/` | 无自动化 UI 测试Terminal.Gui 视图层手工验证)|
| `../../../src/hooks/` | 通过 `QueryEngineTests``AppStateStoreTests` 间接覆盖 |
| `../../../src/state/` | `AppStateStoreTests.cs` 单元测试 |
---
## 参考资料
- [测试与构建总览](../测试与构建.md)
- [测试方案设计](../测试与构建-测试方案设计.md)
- [构建与部署](../测试与构建-构建与部署.md)
- [迁移路线图](../测试与构建-迁移路线图.md)
- [原始代码映射 — 核心模块](../../核心模块设计/reference/原始代码映射-核心模块.md)

View File

@ -0,0 +1,235 @@
# 构建与部署
> 所属项目: free-code .NET 10 重写
> 文档类型: 构建设计
> 来源章节: DESIGN-NET10-PART3.md § 23
> 上级文档: [测试与构建总览](测试与构建.md)
---
## 概述
构建目标是跨 5 个平台的 AOT 原生二进制输出单文件、无运行时依赖的可执行文件。MSBuild 通过 `Directory.Build.props` 统一管理全局属性,`build.sh` 封装多平台批量发布,`install.sh` 提供一键下载安装体验。
原始 TypeScript 项目使用 Bun 打包为单一 JavaScript bundle。.NET 重写的 AOT 路径在启动时间和内存占用上接近原生,同时消除了 Bun 运行时依赖。
---
## 23.1 MSBuild 配置
### Directory.Build.props全局属性
所有子项目自动继承这些属性,无需在每个 `.csproj` 中重复声明。
```xml
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>13.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AotPublish>true</AotPublish>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<Version>1.0.0</Version>
<AssemblyName>free-code</AssemblyName>
<RootNamespace>FreeCode</RootNamespace>
</PropertyGroup>
</Project>
```
关键属性说明:
| 属性 | 值 | 作用 |
|------|----|------|
| `AotPublish` | `true` | 启用 AOT 原生编译 |
| `PublishSingleFile` | `true` | 输出单一可执行文件 |
| `PublishTrimmed` | `true` | 裁剪未使用的程序集 |
| `TrimMode` | `partial` | 部分裁剪,保留反射入口点 |
| `JsonSerializerIsReflectionEnabledByDefault` | `false` | 强制使用 Source Generator 序列化 |
| `EnableAotAnalyzer` | `true` | 编译期检测 AOT 不兼容代码 |
| `LangVersion` | `13.0` | C# 13 特性primary constructors、集合表达式等|
---
### FreeCode.csproj主项目
```xml
<!-- FreeCode.csproj (主项目) -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FreeCode.Core\FreeCode.Core.csproj" />
<ProjectReference Include="..\FreeCode.Engine\FreeCode.Engine.csproj" />
<ProjectReference Include="..\FreeCode.Tools\FreeCode.Tools.csproj" />
<ProjectReference Include="..\FreeCode.Commands\FreeCode.Commands.csproj" />
<ProjectReference Include="..\FreeCode.ApiProviders\FreeCode.ApiProviders.csproj" />
<ProjectReference Include="..\FreeCode.Mcp\FreeCode.Mcp.csproj" />
<ProjectReference Include="..\FreeCode.Lsp\FreeCode.Lsp.csproj" />
<ProjectReference Include="..\FreeCode.Bridge\FreeCode.Bridge.csproj" />
<ProjectReference Include="..\FreeCode.Services\FreeCode.Services.csproj" />
<ProjectReference Include="..\FreeCode.Tasks\FreeCode.Tasks.csproj" />
<ProjectReference Include="..\FreeCode.Skills\FreeCode.Skills.csproj" />
<ProjectReference Include="..\FreeCode.Plugins\FreeCode.Plugins.csproj" />
<ProjectReference Include="..\FreeCode.Features\FreeCode.Features.csproj" />
<ProjectReference Include="..\FreeCode.State\FreeCode.State.csproj" />
<ProjectReference Include="..\FreeCode.TerminalUI\FreeCode.TerminalUI.csproj" />
</ItemGroup>
</Project>
```
主项目引用全部 15 个子项目,自身仅包含 `Program.cs` 入口,所有业务逻辑分布在各子项目库中。
---
## 23.2 构建脚本
### build.sh多平台批量构建
```bash
#!/bin/bash
# build.sh — 多平台构建
set -euo pipefail
VERSION="${1:-$(git describe --tags --always)}"
OUTPUT_DIR="./dist"
# 构建目标平台
PLATFORMS=(
"osx-arm64"
"osx-x64"
"linux-x64"
"linux-arm64"
"win-x64"
)
for PLATFORM in "${PLATFORMS[@]}"; do
echo "Building for $PLATFORM..."
RID="${PLATFORM}"
dotnet publish src/FreeCode/FreeCode.csproj \
-c Release \
-r "$RID" \
-o "$OUTPUT_DIR/$PLATFORM" \
/p:VersionPrefix="$VERSION" \
/p:AotPublish=true \
/p:PublishSingleFile=true \
/p:PublishTrimmed=true
# 重命名二进制
if [[ "$PLATFORM" == win-* ]]; then
mv "$OUTPUT_DIR/$PLATFORM/free-code.exe" \
"$OUTPUT_DIR/$PLATFORM/free-code-$VERSION-$PLATFORM.exe"
else
chmod +x "$OUTPUT_DIR/$PLATFORM/free-code"
mv "$OUTPUT_DIR/$PLATFORM/free-code" \
"$OUTPUT_DIR/$PLATFORM/free-code-$VERSION-$PLATFORM"
fi
echo "✓ $PLATFORM done"
done
echo "All builds complete in $OUTPUT_DIR/"
```
脚本逻辑说明:
1. 版本号优先读取命令行参数fallback 到 `git describe --tags --always`
2. 遍历 5 个 RID每个平台独立调用 `dotnet publish`
3. 输出产物按 `free-code-{VERSION}-{PLATFORM}` 格式命名
4. Windows 平台保留 `.exe` 后缀,其余平台添加可执行权限
---
## 23.3 安装脚本
### install.sh一键安装
```bash
#!/bin/bash
# install.sh — 一键安装
set -euo pipefail
# 检测平台
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS-$ARCH" in
Darwin-arm64) PLATFORM="osx-arm64" ;;
Darwin-x86_64) PLATFORM="osx-x64" ;;
Linux-x86_64) PLATFORM="linux-x64" ;;
Linux-aarch64) PLATFORM="linux-arm64" ;;
*) echo "Unsupported platform: $OS-$ARCH"; exit 1 ;;
esac
INSTALL_DIR="$HOME/.free-code/bin"
mkdir -p "$INSTALL_DIR"
# 下载
BINARY_URL="https://github.com/paoloanzn/free-code/releases/latest/download/free-code-latest-$PLATFORM"
echo "Downloading free-code for $PLATFORM..."
curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/free-code"
chmod +x "$INSTALL_DIR/free-code"
# 添加到 PATH
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
SHELL_RC="$HOME/.zshrc"
[ -f "$HOME/.bashrc" ] && SHELL_RC="$HOME/.bashrc"
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"
echo "Added $INSTALL_DIR to PATH in $SHELL_RC"
fi
echo "✓ free-code installed to $INSTALL_DIR/free-code"
echo " Run 'free-code' to start"
```
安装流程:
1. 通过 `uname -s``uname -m` 检测当前平台
2. 从 GitHub Releases 下载对应 RID 的预编译二进制
3. 安装到 `~/.free-code/bin/free-code`
4. 自动检测 shell 配置文件(优先 `.zshrc`fallback `.bashrc`)并追加 PATH
---
## 23.4 目标平台矩阵
| RID | 操作系统 | 架构 | 说明 |
|-----|---------|------|------|
| `osx-arm64` | macOS | Apple Silicon | M1/M2/M3 Mac |
| `osx-x64` | macOS | Intel x64 | Intel Mac |
| `linux-x64` | Linux | x86-64 | 主流 Linux 服务器 |
| `linux-arm64` | Linux | AArch64 | Raspberry Pi、AWS Graviton 等 |
| `win-x64` | Windows | x64 | Windows 10/11 |
---
## 23.5 发布参数说明
| dotnet publish 参数 | 含义 |
|---------------------|------|
| `-c Release` | Release 配置,开启优化 |
| `-r {RID}` | 目标运行时,指定后产物为自包含 |
| `/p:AotPublish=true` | AOT 原生编译,消除 JIT 开销 |
| `/p:PublishSingleFile=true` | 将所有资源打包为单一可执行文件 |
| `/p:PublishTrimmed=true` | 裁剪未引用的程序集,减小体积 |
| `/p:VersionPrefix={VERSION}` | 注入版本号到程序集元数据 |
AOT 编译将 IL 代码提前编译为目标平台原生机器码,启动时间从 JIT 模式的数百毫秒降至个位数毫秒,与原始 Bun 构建产物的冷启动时间相当。
---
## 参考资料
- [测试与构建总览](测试与构建.md)
- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)
- [迁移路线图](测试与构建-迁移路线图.md)

View File

@ -0,0 +1,369 @@
# 测试方案设计
> 所属项目: free-code .NET 10 重写
> 文档类型: 测试设计
> 来源章节: DESIGN-NET10-PART3.md § 22
> 上级文档: [测试与构建总览](测试与构建.md)
---
## 概述
原始 TypeScript 项目没有系统化的测试覆盖。.NET 重写从零建立分层测试体系,目标是核心路径 80%+ 覆盖率。测试框架组合为 **xUnit 2 + NSubstitute 5 + FluentAssertions 7**,三个独立测试项目分别承载单元、集成和 E2E 三个层次。
---
## 22.1 测试项目结构
```
tests/
├── FreeCode.Tests.Unit/ # 120+ 单元测试
│ ├── Engine/
│ │ ├── QueryEngineTests.cs
│ │ └── SystemPromptBuilderTests.cs
│ ├── Tools/
│ │ ├── BashToolTests.cs
│ │ ├── FileEditToolTests.cs
│ │ ├── FileReadToolTests.cs
│ │ ├── FileWriteToolTests.cs
│ │ ├── GlobToolTests.cs
│ │ ├── GrepToolTests.cs
│ │ ├── AgentToolTests.cs
│ │ └── ToolRegistryTests.cs
│ ├── Commands/
│ │ └── CommandRegistryTests.cs
│ ├── ApiProviders/
│ │ ├── AnthropicProviderTests.cs
│ │ ├── CodexProviderTests.cs
│ │ └── ProviderRouterTests.cs
│ ├── Mcp/
│ │ ├── McpClientTests.cs
│ │ ├── McpClientManagerTests.cs
│ │ └── TransportTests.cs
│ ├── Lsp/
│ │ └── LspClientManagerTests.cs
│ ├── Services/
│ │ ├── AuthServiceTests.cs
│ │ ├── RateLimitServiceTests.cs
│ │ ├── SessionMemoryServiceTests.cs
│ │ ├── CompanionServiceTests.cs
│ │ └── NotificationServiceTests.cs
│ └── State/
│ └── AppStateStoreTests.cs
├── FreeCode.Tests.Integration/ # 60 集成测试
│ ├── QueryPipelineTests.cs
│ ├── McpIntegrationTests.cs
│ ├── LspIntegrationTests.cs
│ ├── BridgeIntegrationTests.cs
│ ├── TaskManagerTests.cs
│ └── PluginLoadingTests.cs
└── FreeCode.Tests.E2E/ # 20 端到端测试
├── CliTests.cs
├── OneShotModeTests.cs
└── InteractiveReplTests.cs
```
---
## 22.2 单元测试示例
### BashToolTests.cs
测试覆盖: 命令执行、超时处理、后台任务、只读分类。
原始来源: `../../src/tools/BashTool.tsx`
```csharp
// === BashToolTests.cs ===
public class BashToolTests
{
private readonly BashTool _tool = new(
Substitute.For<IBackgroundTaskManager>(),
Substitute.For<IFeatureFlagService>());
[Fact]
public async Task ExecuteAsync_SimpleCommand_ReturnsOutput()
{
// Arrange
var input = new BashToolInput { Command = "echo hello" };
var context = new ToolExecutionContext(
WorkingDirectory: Path.GetTempPath(),
PermissionMode: PermissionMode.Default,
AdditionalWorkingDirectories: [],
PermissionEngine: Substitute.For<IPermissionEngine>(),
LspManager: Substitute.For<ILspClientManager>(),
TaskManager: Substitute.For<IBackgroundTaskManager>(),
Services: Substitute.For<IServiceProvider>());
// Act
var result = await _tool.ExecuteAsync(input, context);
// Assert
result.IsError.Should().BeFalse();
result.Data.Stdout.Trim().Should().Be("hello");
result.Data.ExitCode.Should().Be(0);
}
[Fact]
public async Task ExecuteAsync_Timeout_KillsProcess()
{
var input = new BashToolInput { Command = "sleep 10", Timeout = 500 };
var context = CreateContext();
var result = await _tool.ExecuteAsync(input, context);
result.Data.Interrupted.Should().BeTrue();
result.Data.ExitCode.Should().Be(-1);
}
[Fact]
public async Task ExecuteAsync_Background_ReturnsTaskId()
{
var taskManager = Substitute.For<IBackgroundTaskManager>();
taskManager.CreateShellTaskAsync(Arg.Any<string>(), Arg.Any<ProcessStartInfo>())
.Returns(new LocalShellTask { TaskId = "bg-123", Command = "sleep 10" });
var tool = new BashTool(taskManager, Substitute.For<IFeatureFlagService>());
var input = new BashToolInput { Command = "sleep 10", RunInBackground = true };
var result = await tool.ExecuteAsync(input, CreateContext());
result.Data.BackgroundTaskId.Should().Be("bg-123");
}
[Theory]
[InlineData("ls", true)]
[InlineData("rm -rf /", false)]
[InlineData("cat file.txt", true)]
[InlineData("echo test > file", false)]
public void IsReadOnly_ClassifiesCorrectly(string command, bool expected)
{
var input = new BashToolInput { Command = command };
_tool.IsReadOnly(input).Should().Be(expected);
}
}
```
---
### ToolRegistryTests.cs
测试覆盖: 基础工具注册、MCP 工具去重策略。
原始来源: `../../src/tools.ts`
```csharp
// === ToolRegistryTests.cs ===
public class ToolRegistryTests
{
[Fact]
public async Task GetToolsAsync_ReturnsBaseTools()
{
var registry = CreateRegistry();
var tools = await registry.GetToolsAsync();
tools.Should().Contain(t => t.Name == "Read");
tools.Should().Contain(t => t.Name == "Edit");
tools.Should().Contain(t => t.Name == "Bash");
tools.Should().Contain(t => t.Name == "Agent");
}
[Fact]
public async Task AssembleToolPool_DeduplicatesMcpTools()
{
// 内置 "Bash" 工具 + MCP 也提供 "Bash" → 只保留内置
var registry = CreateRegistry();
var mcpManager = Substitute.For<IMcpClientManager>();
mcpManager.GetToolsAsync().Returns(new List<ITool>
{
CreateTool("Bash"), // 与内置重名,应被丢弃
CreateTool("SlackSearch"), // 新工具,应被添加
});
// 验证: Bash 只有一个 (内置), SlackSearch 被添加
}
}
```
---
### AnthropicProviderTests.cs
测试覆盖: SSE 流式事件解析。
原始来源: `../../src/services/api/claude.ts`
```csharp
// === AnthropicProviderTests.cs ===
public class AnthropicProviderTests
{
[Fact]
public async Task StreamAsync_ParsesSSECorrectly()
{
var sseResponse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"Hello\"}}\n\n"
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
var handler = new MockHttpHandler(sseResponse);
var provider = new AnthropicProvider(new HttpClient(handler));
var messages = new List<SDKMessage>();
await foreach (var msg in provider.StreamAsync(new ApiRequest()))
messages.Add(msg);
messages.Should().ContainSingle(m => m is SDKMessage.StreamingDelta sd && sd.Text == "Hello");
}
}
```
---
### CompanionServiceTests.cs
测试覆盖: 骰子结果确定性、不同用户产生不同结果。
原始来源: 无直接对应(新增 Companion 功能)
```csharp
// === CompanionServiceTests.cs ===
public class CompanionServiceTests
{
[Fact]
public void RollBones_IsDeterministic()
{
// 相同 userId → 相同骨骼
var service = new CompanionService(Substitute.For<IGlobalConfig>());
var c1 = service.RollBones("user-123");
var c2 = service.RollBones("user-123");
c1.Should().BeEquivalentTo(c2);
}
[Fact]
public void RollBones_DifferentUsers_DifferentResults()
{
var service = new CompanionService(Substitute.For<IGlobalConfig>());
var c1 = service.RollBones("user-A");
var c2 = service.RollBones("user-B");
// 极大概率不同 (18 种族, 6 眼形, 5 稀有度)
(c1.Species != c2.Species || c1.Eye != c2.Eye).Should().BeTrue();
}
}
```
---
## 22.3 集成测试示例
### McpIntegrationTests.cs
测试覆盖: stdio MCP 服务器连接建立。
原始来源: `../../src/services/mcp/`
```csharp
// === McpIntegrationTests.cs ===
public class McpIntegrationTests : IAsyncLifetime
{
private McpClientManager _manager = null!;
private string _tempDir = null!;
public async Task InitializeAsync()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"mcp-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
// 创建一个测试用 MCP stdio 服务器脚本
var serverScript = Path.Combine(_tempDir, "test-server.sh");
await File.WriteAllTextAsync(serverScript, @"#!/bin/bash
echo '{""jsonrpc"":""2.0"",""id"":1,""result"":{""protocolVersion"":""2025-03-26"",""capabilities"":{""tools"":{}}}}'
");
Process.Start("chmod", $"+x {serverScript}")?.WaitForExit();
}
[Fact]
public async Task ConnectServersAsync_ConnectsToStdioServer()
{
// 配置一个 stdio MCP 服务器
var config = new StdioServerConfig { Command = "echo", Scope = ConfigScope.Local };
// ... 验证连接成功
}
}
```
---
## 22.4 E2E 测试示例
### CliTests.cs
测试覆盖: 版本标志输出、一次性模式响应。
原始来源: `../../src/entrypoints/cli.tsx`
```csharp
// === CliTests.cs ===
public class CliTests
{
[Fact]
public async Task VersionFlag_PrintsVersion()
{
var result = await RunCliAsync("--version");
result.ExitCode.Should().Be(0);
result.Output.Should().StartWith("free-code ");
}
[Fact]
public async Task OneShotMode_ReturnsResponse()
{
// 需要 mock API server
var result = await RunCliAsync("-p", "what is 2+2?");
result.ExitCode.Should().Be(0);
}
private static async Task<(int ExitCode, string Output)> RunCliAsync(params string[] args)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = $"run --project src/FreeCode {string.Join(" ", args)}",
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, output);
}
}
```
---
## 22.5 测试金字塔
| 层次 | 项目 | 数量 | 执行时间目标 |
|------|------|------|------------|
| 单元测试 | `FreeCode.Tests.Unit` | 120+ | < 30 |
| 集成测试 | `FreeCode.Tests.Integration` | 60 | < 3 分钟 |
| E2E 测试 | `FreeCode.Tests.E2E` | 20 | < 10 分钟 |
| **合计** | | **200** | |
单元测试覆盖所有工具、引擎、命令、API 提供商、MCP/LSP 客户端、服务层和状态管理。集成测试验证跨组件交互查询管道、MCP 连接、LSP 通信、Bridge 握手、任务管理器、插件加载。E2E 测试启动完整进程,验证 CLI 行为对外部用户可见的部分。
---
## 22.6 测试框架版本
| 包 | 版本 | 用途 |
|----|------|------|
| `xunit` | 2.x | 测试运行器和断言基础 |
| `NSubstitute` | 5.x | 接口 mock / stub / spy |
| `FluentAssertions` | 7.x | 可读的断言链 |
| `Microsoft.NET.Test.Sdk` | 最新 | dotnet test 集成 |
| `coverlet.collector` | 最新 | 代码覆盖率收集 |
---
## 参考资料
- [测试与构建总览](测试与构建.md)
- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md)
- [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md)
- [基础设施设计](../基础设施设计/基础设施设计.md)

View File

@ -0,0 +1,234 @@
# 迁移路线图
> 所属项目: free-code .NET 10 重写
> 文档类型: 规划设计
> 来源章节: DESIGN-NET10-PART3.md § 24
> 上级文档: [测试与构建总览](测试与构建.md)
---
## 概述
从 TypeScript/Bun 到 .NET 10 的完整重写分 12 个阶段、35 个工作周推进,设置 8 个里程碑节点作为质量检查点。第 36-37 周为缓冲期和文档收尾,实际总工期约 37 周。
路线图遵循"先主路径、后完整功能、最后质量和发布"的顺序,确保每个里程碑都有可运行的产物,而不是等到全部完成才能验证。
---
## 24.1 35 周详细规划
### Phase 1: 基础框架Week 1-4
```
Phase 1: 基础框架 (Week 1-4)
├── Week 1: 解决方案结构 + Directory.Build.props + 所有 17 个项目创建
├── Week 2: DI 容器 + 核心接口 (ITool, ICommand, IApiProvider, IQueryEngine)
├── Week 3: System.CommandLine CLI 解析 + QuickPathHandler
├── Week 4: AppState record + AppStateStore + FeatureFlags
└── 里程碑 M1: dotnet run 输出版本号
```
**目标**: 建立可编译的解决方案骨架,所有接口定义就位,最基础的 CLI 入口可以运行。
---
### Phase 2: 核心引擎Week 5-8
```
Phase 2: 核心引擎 (Week 5-8)
├── Week 5: QueryEngine 流式循环 + Message 类型
├── Week 6: SystemPromptBuilder (6段构建)
├── Week 7: ToolBase<TInput,TOutput> + FluentValidation
├── Week 8: ToolRegistry + CommandRegistry 注册
└── 里程碑 M2: 一次性 prompt 模式工作 (-p)
```
**目标**: 一次性模式(`-p "prompt"`)可以向 API 发送请求并接收流式响应,不需要完整 UI。
---
### Phase 3: API 提供商Week 9-11
```
Phase 3: API 提供商 (Week 9-11)
├── Week 9: AnthropicProvider SSE 解析
├── Week 10: CodexProvider 消息格式适配
├── Week 11: BedrockProvider + VertexProvider + FoundryProvider
└── 里程碑: 所有 5 个提供商路由工作
```
**目标**: 环境变量切换任意一个提供商都能正常处理流式响应。
---
### Phase 4: 核心工具Week 12-15
```
Phase 4: 核心工具 (Week 12-15)
├── Week 12: FileReadTool + FileEditTool + FileWriteTool
├── Week 13: BashTool + GlobTool + GrepTool
├── Week 14: AgentTool + SkillTool
├── Week 15: WebFetch + WebSearch + TodoWrite + AskUser + 其他
└── 里程碑 M3: Bash/Read/Edit 工具链可用
```
**目标**: 模型可以调用文件操作和 Shell 命令工具,完成基本编码任务。
---
### Phase 5: 命令实现Week 16-18
```
Phase 5: 命令实现 (Week 16-18)
├── Week 16: 核心 30 个命令 (help, exit, clear, config, model, login...)
├── Week 17: 功能命令 25 个 (mcp, plugins, skills, tasks...)
├── Week 18: 条件 + 内部命令 15 个
└── 里程碑: 所有 70+ 命令注册
```
**目标**: 全部斜杠命令可以被解析和分发,核心命令(`/help``/model``/login`)有完整实现。
---
### Phase 6: 终端 UIWeek 19-22
```
Phase 6: 终端 UI (Week 19-22)
├── Week 19: Terminal.Gui App + REPLScreen 基本布局
├── Week 20: PromptInput + MessageList + 流式渲染
├── Week 21: PermissionDialog + ToolUseDisplay + StatusBar
├── Week 22: Theme + Keybindings + CompanionSprite
└── 里程碑 M4: 交互式 REPL 可用
```
**目标**: 用户可以在终端中交互式输入 prompt 并看到流式输出,工具调用有权限确认界面。
---
### Phase 7: 基础设施Week 23-25
```
Phase 7: 基础设施 (Week 23-25)
├── Week 23: MCP SDK (传输层 + 客户端 + McpClientManager)
├── Week 24: LSP (LspClientManager + LspServerInstance)
├── Week 25: OAuth + Bridge + BackgroundTasks + RateLimit
└── 里程碑 M5: 功能对等原始项目
```
**目标**: MCP 服务器可以连接并提供工具LSP 集成工作Bridge 模式可以与 IDE 通信,这是与原始 TypeScript 项目功能对等的节点。
---
### Phase 8: 高级服务Week 26-28
```
Phase 8: 高级服务 (Week 26-28)
├── Week 26: SessionMemory + AutoDream
├── Week 27: TeamMemorySync + RemoteSessions
├── Week 28: Voice + Notifications + Companion
└── 里程碑: 所有高级功能工作
```
**目标**: 会话记忆、团队记忆同步、语音输入、Companion 精灵等扩展功能全部就位。
---
### Phase 9: 扩展系统Week 29-30
```
Phase 9: 扩展系统 (Week 29-30)
├── Week 29: SkillLoader + frontmatter 解析
├── Week 30: PluginManager + AssemblyLoadContext + Marketplace
└── 里程碑 M6: 可加载外部技能和插件
```
**目标**: 外部 `.md` 技能文件和 `.dll` 插件可以在运行时动态加载,插件市场接口就位。
---
### Phase 10: 测试Week 31-33
```
Phase 10: 测试 (Week 31-33)
├── Week 31: 120 单元测试 (工具/引擎/提供商/服务)
├── Week 32: 60 集成测试 (MCP/LSP/Bridge/管道)
├── Week 33: 20 E2E 测试 + 性能基准
└── 里程碑 M7: 核心路径 80%+ 覆盖率
```
**目标**: 三层测试套件全部通过,覆盖率报告显示核心路径 80% 以上,性能基准记录在案。
---
### Phase 11: 构建发布Week 34-35
```
Phase 11: 构建发布 (Week 34-35)
├── Week 34: AOT 优化 + Source Generator + 5 平台构建
├── Week 35: 安装脚本 + CI/CD + 文档
└── 里程碑 M8: 一键安装脚本工作
```
**目标**: `install.sh` 可以在 macOS 和 Linux 上一键安装CI/CD 管道自动构建和发布。
---
### Phase 12: 文档Week 36-37缓冲期
```
Phase 12: 文档 (Week 36-37, buffer)
├── README + 架构文档 + API 文档
└── 最终验收
```
**目标**: 文档完整,所有设计文档更新为最终实现,项目可以公开发布。
---
## 24.2 里程碑表
| 里程碑 | 完成周 | 验收标准 | 依赖 |
|--------|--------|---------|------|
| M1 | Week 4 | `dotnet run` 输出版本号,无运行时错误 | Phase 1 完成 |
| M2 | Week 8 | `-p "hello"` 返回 API 响应,流式输出到终端 | Phase 2 完成 |
| M3 | Week 15 | 模型成功调用 Bash/Read/Edit 工具完成简单任务 | Phase 4 完成 |
| M4 | Week 22 | 交互式 REPL 可用,权限确认 UI 正常 | Phase 6 完成 |
| M5 | Week 25 | 功能对等原始 TypeScript 项目MCP/LSP/Bridge 工作 | Phase 7 完成 |
| M6 | Week 30 | 可加载外部技能文件和插件 dll | Phase 9 完成 |
| M7 | Week 33 | 测试覆盖率 >= 80%,全部测试通过 | Phase 10 完成 |
| M8 | Week 35 | 一键安装脚本在 macOS/Linux 工作CI 绿灯 | Phase 11 完成 |
---
## 24.3 风险评估
| 风险 | 概率 | 影响 | 缓解策略 |
|------|------|------|---------|
| MCP SDK 无成熟 .NET 库 | 高 | 高 | Phase 7 前期自研传输层,参考官方 TypeScript SDK 实现 |
| Terminal.Gui v2 组件不足 | 中 | 中 | Spectre.Console 补充缺失组件,必要时自定义 View |
| AOT 反射限制 | 中 | 高 | Source Generator 生成序列化代码,启用 `EnableAotAnalyzer` 提前暴露问题 |
| 插件 ALC 卸载内存泄漏 | 低 | 中 | 定期监控内存,提供强制 GC 入口,文档说明已知限制 |
| LSP OmniSharp 库兼容性 | 中 | 中 | StreamJsonRpc 作为备选,最坏情况自实现 JSON-RPC 层 |
| StreamJsonRpc AOT 兼容 | 中 | 中 | 评估期间同时准备自定义 JSON-RPC 实现Phase 7 初期决策 |
---
## 24.4 并行开发策略
各阶段内部的周可以部分并行推进。具体来说:
- Phase 3 的三家次要提供商Bedrock、Vertex、Foundry可以分配给不同开发者同时实现
- Phase 4 中文件操作工具Week 12和 Shell 工具Week 13无依赖关系可并行
- Phase 10 的单元测试可以在 Phase 7-9 期间随功能完成同步编写,不必等到 Phase 10 才开始
单人开发时按串行顺序执行,团队协作时按上述拆分并行。
---
## 参考资料
- [测试与构建总览](测试与构建.md)
- [测试方案设计](测试与构建-测试方案设计.md)
- [构建与部署](测试与构建-构建与部署.md)
- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)

View File

@ -0,0 +1,90 @@
# 测试与构建 — 总览
> 所属项目: free-code .NET 10 重写
> 配套文档: [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md) | [核心模块设计](../核心模块设计/核心模块设计.md) | [参考映射](reference/原始代码映射-测试与构建.md)
---
## 概述
本组文档覆盖 free-code .NET 10 重写项目的质量保证、构建发布和迁移规划三个方面。原始 TypeScript 项目没有系统化的测试套件;.NET 重写从零开始建立完整的分层测试体系,并以 AOT 单文件发布为目标设计构建管道。
测试框架采用 **xUnit 2 + NSubstitute 5 + FluentAssertions 7** 组合。目标覆盖率是核心路径 80%+,由 200 个测试用例120 单元 + 60 集成 + 20 E2E构成测试金字塔。
构建目标是跨 5 个平台macOS arm64/x64、Linux x64/arm64、Windows x64的 AOT 原生二进制,通过 `build.sh` 脚本批量发布,配合 `install.sh` 实现一键安装。
迁移路线图覆盖 35 个工作周,分 12 个阶段,设置 8 个里程碑节点,附带风险评估矩阵。
---
## 子模块列表
### [测试方案设计](测试与构建-测试方案设计.md)
覆盖三层测试项目结构、单元测试示例(`BashToolTests``ToolRegistryTests``AnthropicProviderTests``CompanionServiceTests`)、集成测试示例(`McpIntegrationTests`、E2E 测试示例(`CliTests`),以及测试金字塔数量分布。
原始来源: 无直接对应(原始项目无系统测试)
---
### [构建与部署](测试与构建-构建与部署.md)
覆盖 `Directory.Build.props` 全局 MSBuild 配置、`FreeCode.csproj` 主项目引用、`build.sh` 多平台构建脚本、`install.sh` 一键安装脚本,以及 AOT + 单文件 + Trimmed 发布参数。
原始来源: `scripts/build.ts`Bun 构建脚本)、`install.sh`(原始安装脚本)
---
### [迁移路线图](测试与构建-迁移路线图.md)
覆盖 35 周 12 阶段详细规划、M1-M8 里程碑说明表,以及风险评估与缓解策略矩阵。
原始来源: 无直接对应(新增规划文档)
---
## 测试金字塔
```
[E2E: 20]
/ CliTests \
/ OneShotMode \
/ InteractiveRepl \
==================
[集成: 60]
/ QueryPipeline \
/ McpIntegration \
/ LspIntegration \
/ BridgeIntegration \
/ TaskManager Plugins \
=================================
[单元: 120]
Engine / Tools / Commands /
ApiProviders / Mcp / Lsp /
Services / State
```
---
## 关键设计决策
**测试替身策略**
所有外部依赖HTTP 客户端、文件系统、进程管理)通过接口注入,测试中由 NSubstitute 的 `Substitute.For<T>()` 替换,确保单元测试完全隔离,不依赖网络或本地环境。
**AOT 兼容优先**
构建配置全面启用 `AotPublish``PublishTrimmed``EnableAotAnalyzer`确保代码在编译期就暴露反射问题而不是在生产运行时崩溃。JSON 序列化通过 Source Generator 生成,完全不依赖运行时反射。
**平台矩阵覆盖**
`build.sh``osx-arm64``osx-x64``linux-x64``linux-arm64``win-x64` 五个 RID 各生成独立的原生二进制,与原始 Bun 构建脚本的单平台模式相比,构建产物更小、启动更快。
---
## 参考资料
- [原始代码映射 — 测试与构建](reference/原始代码映射-测试与构建.md)
- [总体概述与技术选型](../总体概述与技术选型/总体概述与技术选型.md)
- [核心模块设计 — 工具系统](../核心模块设计/核心模块设计-工具系统.md)
- [基础设施设计](../基础设施设计/基础设施设计.md)

31
easy-code.slnx Normal file
View File

@ -0,0 +1,31 @@
<Solution>
<Folder Name="/src/">
<Project Path="src\FreeCode\FreeCode.csproj" />
<Project Path="src\FreeCode.Core\FreeCode.Core.csproj" />
<Project Path="src\FreeCode.Engine\FreeCode.Engine.csproj" />
<Project Path="src\FreeCode.Tools\FreeCode.Tools.csproj" />
<Project Path="src\FreeCode.Commands\FreeCode.Commands.csproj" />
<Project Path="src\FreeCode.ApiProviders\FreeCode.ApiProviders.csproj" />
<Project Path="src\FreeCode.Mcp\FreeCode.Mcp.csproj" />
<Project Path="src\FreeCode.Lsp\FreeCode.Lsp.csproj" />
<Project Path="src\FreeCode.Bridge\FreeCode.Bridge.csproj" />
<Project Path="src\FreeCode.Services\FreeCode.Services.csproj" />
<Project Path="src\FreeCode.Tasks\FreeCode.Tasks.csproj" />
<Project Path="src\FreeCode.Skills\FreeCode.Skills.csproj" />
<Project Path="src\FreeCode.Plugins\FreeCode.Plugins.csproj" />
<Project Path="src\FreeCode.Features\FreeCode.Features.csproj" />
<Project Path="src\FreeCode.State\FreeCode.State.csproj" />
<Project Path="src\FreeCode.TerminalUI\FreeCode.TerminalUI.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests\FreeCode.Core.Tests\FreeCode.Core.Tests.csproj" />
<Project Path="tests\FreeCode.Engine.Tests\FreeCode.Engine.Tests.csproj" />
<Project Path="tests\FreeCode.Tools.Tests\FreeCode.Tools.Tests.csproj" />
<Project Path="tests\FreeCode.Commands.Tests\FreeCode.Commands.Tests.csproj" />
<Project Path="tests\FreeCode.ApiProviders.Tests\FreeCode.ApiProviders.Tests.csproj" />
<Project Path="tests\FreeCode.Mcp.Tests\FreeCode.Mcp.Tests.csproj" />
<Project Path="tests\FreeCode.Services.Tests\FreeCode.Services.Tests.csproj" />
<Project Path="tests\FreeCode.Tasks.Tests\FreeCode.Tasks.Tests.csproj" />
<Project Path="tests\FreeCode.Integration.Tests\FreeCode.Integration.Tests.csproj" />
</Folder>
</Solution>

24
scripts/build.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOLUTION_ROOT="$(dirname "$SCRIPT_DIR")"
VERSION="${1:-0.1.0}"
echo "Building free-code .NET v${VERSION}..."
RID_TARGETS=("osx-arm64" "osx-x64" "linux-x64" "linux-arm64" "win-x64")
for RID in "${RID_TARGETS[@]}"; do
echo "Publishing for ${RID}..."
dotnet publish "${SOLUTION_ROOT}/src/FreeCode/FreeCode.csproj" \
-c Release \
-r "${RID}" \
/p:Version="${VERSION}" \
/p:PublishAot=true \
/p:PublishSingleFile=true \
/p:PublishTrimmed=true \
-o "${SOLUTION_ROOT}/dist/${RID}"
done
echo "Build complete. Outputs in ${SOLUTION_ROOT}/dist/"

55
scripts/install.sh Executable file
View File

@ -0,0 +1,55 @@
#!/bin/bash
set -euo pipefail
REPO="free-code/free-code"
INSTALL_DIR="${HOME}/.free-code/bin"
echo "Installing free-code..."
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "${OS}" in
darwin) OS="osx" ;;
linux) OS="linux" ;;
*) echo "Unsupported OS: ${OS}"; exit 1 ;;
esac
case "${ARCH}" in
x86_64|amd64) ARCH="x64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: ${ARCH}"; exit 1 ;;
esac
RID="${OS}-${ARCH}"
BINARY_NAME="free-code"
if [ "${OS}" = "win" ]; then
BINARY_NAME="free-code.exe"
fi
mkdir -p "${INSTALL_DIR}"
echo "Downloading ${RID} binary..."
echo "Building from source..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
dotnet publish "${SCRIPT_DIR}/../src/FreeCode/FreeCode.csproj" \
-c Release \
-r "${RID}" \
/p:PublishAot=true \
/p:PublishSingleFile=true \
/p:PublishTrimmed=true \
-o "${INSTALL_DIR}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true
echo "Adding to PATH..."
PROFILE_FILES=("${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.profile")
for PROFILE in "${PROFILE_FILES[@]}"; do
if [ -f "${PROFILE}" ] && ! grep -q "${INSTALL_DIR}" "${PROFILE}" 2>/dev/null; then
printf 'export PATH="%s:$PATH"\n' "${INSTALL_DIR}" >> "${PROFILE}"
echo "Added to ${PROFILE}"
fi
done
echo "Installation complete. Run 'source ~/.bashrc' or start a new terminal."

View File

@ -0,0 +1,187 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FreeCode.Core.Models;
namespace FreeCode.ApiProviders;
internal sealed record PendingToolUse(string Id, string Name, StringBuilder JsonBuilder);
public sealed class AnthropicProvider : FreeCode.Core.Interfaces.IApiProvider
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string? _apiKey;
private readonly string _model;
public AnthropicProvider(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
_baseUrl = Environment.GetEnvironmentVariable("ANTHROPIC_BASE_URL") ?? "https://api.anthropic.com";
_apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
?? Environment.GetEnvironmentVariable("ANTHROPIC_AUTH_TOKEN");
_model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-sonnet-4-6";
}
public async IAsyncEnumerable<SDKMessage> StreamAsync(ApiRequest request, CancellationToken ct = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), "/v1/messages"));
httpRequest.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01");
httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
if (!string.IsNullOrWhiteSpace(_apiKey))
{
httpRequest.Headers.TryAddWithoutValidation("x-api-key", _apiKey);
httpRequest.Headers.TryAddWithoutValidation("authorization", $"Bearer {_apiKey}");
}
var payload = new
{
model = request.Model ?? _model,
system = request.SystemPrompt,
messages = request.Messages,
tools = request.Tools,
stream = true,
max_tokens = 4096
};
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var pendingToolUses = new Dictionary<int, PendingToolUse>();
await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false))
{
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var typeProperty))
{
continue;
}
var type = typeProperty.GetString();
switch (type)
{
case "content_block_start":
if (root.TryGetProperty("content_block", out var contentBlock)
&& contentBlock.TryGetProperty("type", out var blockType))
{
var blockTypeStr = blockType.GetString();
var index = root.TryGetProperty("index", out var indexProp) ? indexProp.GetInt32() : -1;
if (string.Equals(blockTypeStr, "tool_use", StringComparison.OrdinalIgnoreCase) && index >= 0)
{
pendingToolUses[index] = new PendingToolUse(
contentBlock.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty,
contentBlock.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty,
new StringBuilder());
}
else if (string.Equals(blockTypeStr, "text", StringComparison.OrdinalIgnoreCase))
{
if (contentBlock.TryGetProperty("text", out var startText))
{
var startTextValue = startText.GetString();
if (!string.IsNullOrEmpty(startTextValue))
{
yield return new SDKMessage.StreamingDelta(startTextValue);
}
}
}
}
break;
case "content_block_delta":
if (root.TryGetProperty("delta", out var delta))
{
var deltaIndex = root.TryGetProperty("index", out var di) ? di.GetInt32() : -1;
if (delta.TryGetProperty("text", out var textDelta))
{
yield return new SDKMessage.StreamingDelta(textDelta.GetString() ?? string.Empty);
}
else if (delta.TryGetProperty("partial_json", out var partialJson) && deltaIndex >= 0 && pendingToolUses.TryGetValue(deltaIndex, out var pending))
{
pending.JsonBuilder.Append(partialJson.GetString() ?? string.Empty);
}
}
break;
case "content_block_stop":
var stopIndex = root.TryGetProperty("index", out var si) ? si.GetInt32() : -1;
if (stopIndex >= 0 && pendingToolUses.TryGetValue(stopIndex, out var completed))
{
pendingToolUses.Remove(stopIndex);
var rawJson = completed.JsonBuilder.ToString();
JsonElement toolInput;
if (string.IsNullOrWhiteSpace(rawJson))
{
toolInput = JsonDocument.Parse("{}").RootElement.Clone();
}
else
{
using var inputDoc = JsonDocument.Parse(rawJson);
toolInput = inputDoc.RootElement.Clone();
}
yield return new SDKMessage.ToolUseStart(completed.Id, completed.Name, toolInput);
}
break;
case "message_stop":
yield break;
}
}
}
private static async IAsyncEnumerable<string> ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
var buffer = new StringBuilder();
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
yield break;
}
if (line.Length == 0)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var data = line.AsSpan(5).TrimStart();
if (data.Length == 0)
{
continue;
}
if (buffer.Length > 0)
{
buffer.Append('\n');
}
buffer.Append(data);
}
}
}
}

View File

@ -0,0 +1,37 @@
using FreeCode.Core.Enums;
using FreeCode.Core.Interfaces;
namespace FreeCode.ApiProviders;
public class ApiProviderRouter : IApiProviderRouter
{
private readonly IServiceProvider _services;
private readonly ApiProviderType _activeProvider;
public ApiProviderRouter(IServiceProvider services)
{
_services = services;
_activeProvider = DetectProvider();
}
private static ApiProviderType DetectProvider()
{
if (Env("CLAUDE_CODE_USE_OPENAI") == "1") return ApiProviderType.OpenAICodex;
if (Env("CLAUDE_CODE_USE_BEDROCK") == "1") return ApiProviderType.AwsBedrock;
if (Env("CLAUDE_CODE_USE_VERTEX") == "1") return ApiProviderType.GoogleVertex;
if (Env("CLAUDE_CODE_USE_FOUNDRY") == "1") return ApiProviderType.AnthropicFoundry;
return ApiProviderType.Anthropic;
}
public IApiProvider GetActiveProvider() => _activeProvider switch
{
ApiProviderType.Anthropic => (AnthropicProvider)_services.GetService(typeof(AnthropicProvider))!,
ApiProviderType.OpenAICodex => (CodexProvider)_services.GetService(typeof(CodexProvider))!,
ApiProviderType.AwsBedrock => (BedrockProvider)_services.GetService(typeof(BedrockProvider))!,
ApiProviderType.GoogleVertex => (VertexProvider)_services.GetService(typeof(VertexProvider))!,
ApiProviderType.AnthropicFoundry => (FoundryProvider)_services.GetService(typeof(FoundryProvider))!,
_ => throw new InvalidOperationException($"Unknown provider: {_activeProvider}")
};
private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
}

View File

@ -0,0 +1,232 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
using Microsoft.Extensions.Configuration;
namespace FreeCode.ApiProviders;
public sealed class BedrockProvider : FreeCode.Core.Interfaces.IApiProvider
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string _region;
private readonly string _modelPrefix;
private readonly string? _bearerToken;
private readonly string? _apiKey;
private readonly bool _skipAuth;
private readonly IRateLimitService? _rateLimitService;
public BedrockProvider(HttpClient? httpClient = null, IConfiguration? configuration = null, IRateLimitService? rateLimitService = null)
{
_httpClient = httpClient ?? new HttpClient();
_rateLimitService = rateLimitService;
_region = GetSetting(configuration, "AWS_REGION", "Bedrock:Region")
?? GetSetting(configuration, "AWS_DEFAULT_REGION", "Bedrock:DefaultRegion")
?? "us-east-1";
_baseUrl = GetSetting(configuration, "ANTHROPIC_BEDROCK_BASE_URL", "Bedrock:BaseUrl")
?? $"https://bedrock-runtime.{_region}.amazonaws.com";
_modelPrefix = GetSetting(configuration, "AWS_BEDROCK_MODEL_PREFIX", "Bedrock:ModelPrefix") ?? "us.anthropic";
_bearerToken = ResolveBearerToken(configuration);
_apiKey = GetSetting(configuration, "ANTHROPIC_BEDROCK_API_KEY", "Bedrock:ApiKey");
_skipAuth = string.Equals(GetSetting(configuration, "CLAUDE_CODE_SKIP_BEDROCK_AUTH", "Bedrock:SkipAuth"), "1", StringComparison.OrdinalIgnoreCase);
}
public async IAsyncEnumerable<SDKMessage> StreamAsync(ApiRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
var model = request.Model ?? "claude-sonnet-4-6";
var modelId = model.Contains(':', StringComparison.Ordinal) ? model : $"{_modelPrefix}.{model}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/model/{Uri.EscapeDataString(modelId)}/invoke-with-response-stream"));
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (!_skipAuth && !string.IsNullOrWhiteSpace(_bearerToken))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken);
}
else if (!_skipAuth && !string.IsNullOrWhiteSpace(_apiKey))
{
httpRequest.Headers.Add("x-api-key", _apiKey);
}
var payload = new
{
anthropic_version = "2023-06-01",
system = request.SystemPrompt,
messages = request.Messages,
tools = request.Tools,
max_tokens = 4096,
stream = true
};
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
var responseHeaders = ToHeaderDictionary(response.Headers, response.Content.Headers);
if (_rateLimitService?.CanProceed(responseHeaders) == false)
{
throw CreateRateLimitException(responseHeaders);
}
response.EnsureSuccessStatusCode();
await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false))
{
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var typeProperty))
{
continue;
}
switch (typeProperty.GetString())
{
case "content_block_delta":
if (root.TryGetProperty("delta", out var delta))
{
if (delta.TryGetProperty("text", out var text))
{
yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty);
}
else if (delta.TryGetProperty("partial_json", out var partialJson))
{
yield return new SDKMessage.StreamingDelta(partialJson.GetString() ?? string.Empty);
}
}
break;
case "content_block_start":
if (root.TryGetProperty("content_block", out var contentBlock)
&& contentBlock.TryGetProperty("type", out var blockType)
&& string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
var input = contentBlock.TryGetProperty("input", out var inputProperty)
? inputProperty.Clone()
: JsonDocument.Parse("{}").RootElement.Clone();
yield return new SDKMessage.ToolUseStart(
contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty,
contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty,
input);
}
break;
case "message_stop":
case "response.completed":
yield break;
}
}
}
private static string? ResolveBearerToken(IConfiguration? configuration)
{
var credentialsFromEnv = GetSetting(configuration,
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_SESSION_TOKEN",
"AWS_ACCESS_TOKEN",
"Bedrock:BearerToken");
if (!string.IsNullOrWhiteSpace(credentialsFromEnv))
{
return credentialsFromEnv;
}
var accessKey = GetSetting(configuration, "AWS_ACCESS_KEY_ID", "Bedrock:AccessKeyId");
var secretKey = GetSetting(configuration, "AWS_SECRET_ACCESS_KEY", "Bedrock:SecretAccessKey");
if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey))
{
var sessionToken = GetSetting(configuration, "AWS_SESSION_TOKEN", "Bedrock:SessionToken");
return string.IsNullOrWhiteSpace(sessionToken)
? $"{accessKey}:{secretKey}"
: $"{accessKey}:{secretKey}:{sessionToken}";
}
return null;
}
private static string? GetSetting(IConfiguration? configuration, params string[] keys)
{
foreach (var key in keys)
{
var value = Environment.GetEnvironmentVariable(key) ?? configuration?[key];
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static Dictionary<string, string> ToHeaderDictionary(HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in responseHeaders)
{
headers[header.Key] = string.Join(",", header.Value);
}
foreach (var header in contentHeaders)
{
headers[header.Key] = string.Join(",", header.Value);
}
return headers;
}
private Exception CreateRateLimitException(IReadOnlyDictionary<string, string> headers)
{
var retryAfter = _rateLimitService?.GetRetryAfter(headers as IDictionary<string, string> ?? new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase));
return retryAfter is { } delay && delay > TimeSpan.Zero
? new HttpRequestException($"Bedrock rate limit exceeded. Retry after {delay.TotalSeconds:F0} seconds.")
: new HttpRequestException("Bedrock rate limit exceeded.");
}
private static async IAsyncEnumerable<string> ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
var buffer = new StringBuilder();
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
yield break;
}
if (line.Length == 0)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var data = line.AsSpan(5).TrimStart();
if (data.Length == 0)
{
continue;
}
if (buffer.Length > 0)
{
buffer.Append('\n');
}
buffer.Append(data);
}
}
}
}

View File

@ -0,0 +1,151 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FreeCode.Core.Models;
namespace FreeCode.ApiProviders;
public sealed class CodexProvider : FreeCode.Core.Interfaces.IApiProvider
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string? _token;
private readonly string _model;
public CodexProvider(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
_baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com";
_token = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? Environment.GetEnvironmentVariable("OPENAI_BEARER_TOKEN")
?? Environment.GetEnvironmentVariable("CLAUDE_CODE_OPENAI_API_KEY");
_model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5.3-codex";
}
public async IAsyncEnumerable<SDKMessage> StreamAsync(ApiRequest request, CancellationToken ct = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), "/v1/responses"));
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (!string.IsNullOrWhiteSpace(_token))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
}
var payload = new
{
model = request.Model ?? _model,
input = request.Messages,
instructions = request.SystemPrompt,
tools = request.Tools,
stream = true
};
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false))
{
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
var type = root.TryGetProperty("type", out var typeProperty) ? typeProperty.GetString() : null;
switch (type)
{
case "response.output_text.delta":
if (root.TryGetProperty("delta", out var delta) || root.TryGetProperty("text", out delta))
{
yield return new SDKMessage.StreamingDelta(delta.GetString() ?? string.Empty);
}
break;
case "response.output_item.added":
if (root.TryGetProperty("item", out var item)
&& item.TryGetProperty("type", out var itemType)
&& (string.Equals(itemType.GetString(), "function_call", StringComparison.OrdinalIgnoreCase)
|| string.Equals(itemType.GetString(), "tool_call", StringComparison.OrdinalIgnoreCase)))
{
var input = item.TryGetProperty("arguments", out var args)
? ParseJsonOrEmpty(args.GetString())
: JsonDocument.Parse("{}").RootElement.Clone();
yield return new SDKMessage.ToolUseStart(
item.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty,
item.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty,
input);
}
break;
case "response.completed":
case "response.failed":
yield break;
}
}
}
private static JsonElement ParseJsonOrEmpty(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return JsonDocument.Parse("{}").RootElement.Clone();
}
try
{
return JsonDocument.Parse(json).RootElement.Clone();
}
catch (JsonException)
{
return JsonDocument.Parse("{}").RootElement.Clone();
}
}
private static async IAsyncEnumerable<string> ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
var buffer = new StringBuilder();
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
yield break;
}
if (line.Length == 0)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var data = line.AsSpan(5).TrimStart();
if (data.Length == 0)
{
continue;
}
if (buffer.Length > 0)
{
buffer.Append('\n');
}
buffer.Append(data);
}
}
}
}

View File

@ -0,0 +1,141 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FreeCode.Core.Models;
namespace FreeCode.ApiProviders;
public sealed class FoundryProvider : FreeCode.Core.Interfaces.IApiProvider
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string? _apiKey;
private readonly string _deployment;
public FoundryProvider(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
_baseUrl = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_BASE_URL")
?? Environment.GetEnvironmentVariable("ANTHROPIC_BASE_URL")
?? "https://api.anthropic.com";
_apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_API_KEY")
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
_deployment = Environment.GetEnvironmentVariable("ANTHROPIC_FOUNDRY_DEPLOYMENT") ?? "default";
}
public async IAsyncEnumerable<SDKMessage> StreamAsync(ApiRequest request, CancellationToken ct = default)
{
var model = request.Model ?? _deployment;
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/v1/deployments/{Uri.EscapeDataString(model)}/messages"));
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (!string.IsNullOrWhiteSpace(_apiKey))
{
httpRequest.Headers.TryAddWithoutValidation("x-api-key", _apiKey);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
}
var payload = new
{
model,
system = request.SystemPrompt,
messages = request.Messages,
tools = request.Tools,
stream = true,
max_tokens = 4096
};
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false))
{
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var typeProperty))
{
continue;
}
switch (typeProperty.GetString())
{
case "content_block_delta":
if (root.TryGetProperty("delta", out var delta) && delta.TryGetProperty("text", out var text))
{
yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty);
}
break;
case "content_block_start":
if (root.TryGetProperty("content_block", out var contentBlock)
&& contentBlock.TryGetProperty("type", out var blockType)
&& string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
var input = contentBlock.TryGetProperty("input", out var inputProperty)
? inputProperty.Clone()
: JsonDocument.Parse("{}").RootElement.Clone();
yield return new SDKMessage.ToolUseStart(
contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty,
contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty,
input);
}
break;
case "message_stop":
case "response.completed":
yield break;
}
}
}
private static async IAsyncEnumerable<string> ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
var buffer = new StringBuilder();
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
yield break;
}
if (line.Length == 0)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var data = line.AsSpan(5).TrimStart();
if (data.Length == 0)
{
continue;
}
if (buffer.Length > 0)
{
buffer.Append('\n');
}
buffer.Append(data);
}
}
}
}

View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>FreeCode.ApiProviders</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FreeCode.Core\FreeCode.Core.csproj" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
namespace FreeCode.ApiProviders;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFreeCodeApiProviders(this IServiceCollection services)
{
services.AddSingleton<FreeCode.Core.Interfaces.IApiProviderRouter, ApiProviderRouter>();
services.AddSingleton<AnthropicProvider>();
services.AddSingleton<CodexProvider>();
services.AddSingleton<BedrockProvider>();
services.AddSingleton<VertexProvider>();
services.AddSingleton<FoundryProvider>();
return services;
}
}

View File

@ -0,0 +1,246 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
using Microsoft.Extensions.Configuration;
namespace FreeCode.ApiProviders;
public sealed class VertexProvider : FreeCode.Core.Interfaces.IApiProvider
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly string _projectId;
private readonly string _location;
private readonly string _baseUrl;
private readonly string? _accessToken;
private readonly IRateLimitService? _rateLimitService;
public VertexProvider(HttpClient? httpClient = null, IConfiguration? configuration = null, IRateLimitService? rateLimitService = null)
{
_httpClient = httpClient ?? new HttpClient();
_rateLimitService = rateLimitService;
_projectId = GetSetting(configuration,
"GOOGLE_CLOUD_PROJECT",
"GCP_PROJECT",
"GOOGLE_PROJECT_ID",
"Vertex:ProjectId")
?? "unknown-project";
_location = GetSetting(configuration, "VERTEX_LOCATION", "GOOGLE_CLOUD_LOCATION", "Vertex:Location")
?? "us-central1";
_baseUrl = GetSetting(configuration, "VERTEX_BASE_URL", "Vertex:BaseUrl") ?? "https://aiplatform.googleapis.com";
_accessToken = ResolveAccessToken(configuration);
}
public async IAsyncEnumerable<SDKMessage> StreamAsync(ApiRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
var model = request.Model ?? "claude-sonnet-4-6";
var url = new Uri(new Uri(_baseUrl.TrimEnd('/')), $"/v1/projects/{Uri.EscapeDataString(_projectId)}/locations/{Uri.EscapeDataString(_location)}/publishers/anthropic/models/{Uri.EscapeDataString(model)}:streamRawPredict");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
if (!string.IsNullOrWhiteSpace(_accessToken))
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
}
var payload = new
{
system = request.SystemPrompt,
contents = request.Messages,
tools = request.Tools,
stream = true
};
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
var responseHeaders = ToHeaderDictionary(response.Headers, response.Content.Headers);
if (_rateLimitService?.CanProceed(responseHeaders) == false)
{
throw CreateRateLimitException(responseHeaders);
}
response.EnsureSuccessStatusCode();
await foreach (var data in ReadSseDataAsync(await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false), ct).ConfigureAwait(false))
{
using var document = JsonDocument.Parse(data);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var typeProperty))
{
continue;
}
switch (typeProperty.GetString())
{
case "content_block_delta":
if (root.TryGetProperty("delta", out var delta) && delta.TryGetProperty("text", out var text))
{
yield return new SDKMessage.StreamingDelta(text.GetString() ?? string.Empty);
}
break;
case "content_block_start":
if (root.TryGetProperty("content_block", out var contentBlock)
&& contentBlock.TryGetProperty("type", out var blockType)
&& string.Equals(blockType.GetString(), "tool_use", StringComparison.OrdinalIgnoreCase))
{
var input = contentBlock.TryGetProperty("input", out var inputProperty)
? inputProperty.Clone()
: JsonDocument.Parse("{}").RootElement.Clone();
yield return new SDKMessage.ToolUseStart(
contentBlock.TryGetProperty("id", out var idProperty) ? idProperty.GetString() ?? string.Empty : string.Empty,
contentBlock.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() ?? string.Empty : string.Empty,
input);
}
break;
case "message_stop":
case "response.completed":
yield break;
}
}
}
private static string? ResolveAccessToken(IConfiguration? configuration)
{
var accessToken = GetSetting(configuration,
"GOOGLE_OAUTH_ACCESS_TOKEN",
"GCP_ACCESS_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS_ACCESS_TOKEN",
"Vertex:AccessToken");
if (!string.IsNullOrWhiteSpace(accessToken))
{
return accessToken;
}
var credentialsJson = GetSetting(configuration, "GOOGLE_APPLICATION_CREDENTIALS_JSON", "Vertex:CredentialsJson");
if (!string.IsNullOrWhiteSpace(credentialsJson))
{
try
{
using var document = JsonDocument.Parse(credentialsJson);
if (document.RootElement.TryGetProperty("access_token", out var tokenElement))
{
return tokenElement.GetString();
}
}
catch (JsonException)
{
return null;
}
}
var credentialsPath = GetSetting(configuration, "GOOGLE_APPLICATION_CREDENTIALS", "Vertex:CredentialsPath");
if (!string.IsNullOrWhiteSpace(credentialsPath) && File.Exists(credentialsPath))
{
try
{
using var document = JsonDocument.Parse(File.ReadAllText(credentialsPath));
if (document.RootElement.TryGetProperty("access_token", out var tokenElement))
{
return tokenElement.GetString();
}
}
catch (IOException)
{
return null;
}
catch (JsonException)
{
return null;
}
}
return null;
}
private static string? GetSetting(IConfiguration? configuration, params string[] keys)
{
foreach (var key in keys)
{
var value = Environment.GetEnvironmentVariable(key) ?? configuration?[key];
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
private static Dictionary<string, string> ToHeaderDictionary(HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var header in responseHeaders)
{
headers[header.Key] = string.Join(",", header.Value);
}
foreach (var header in contentHeaders)
{
headers[header.Key] = string.Join(",", header.Value);
}
return headers;
}
private Exception CreateRateLimitException(IReadOnlyDictionary<string, string> headers)
{
var retryAfter = _rateLimitService?.GetRetryAfter(headers as IDictionary<string, string> ?? new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase));
return retryAfter is { } delay && delay > TimeSpan.Zero
? new HttpRequestException($"Vertex rate limit exceeded. Retry after {delay.TotalSeconds:F0} seconds.")
: new HttpRequestException("Vertex rate limit exceeded.");
}
private static async IAsyncEnumerable<string> ReadSseDataAsync(Stream stream, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false);
var buffer = new StringBuilder();
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
if (line is null)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
yield break;
}
if (line.Length == 0)
{
if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
continue;
}
if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var data = line.AsSpan(5).TrimStart();
if (data.Length == 0)
{
continue;
}
if (buffer.Length > 0)
{
buffer.Append('\n');
}
buffer.Append(data);
}
}
}
}

View File

@ -0,0 +1,120 @@
using System.Net.Http.Json;
using FreeCode.Core.Enums;
using FreeCode.Core.Models;
namespace FreeCode.Bridge;
public sealed class BridgeApiClient
{
private readonly HttpClient _httpClient;
public BridgeApiClient(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
public async Task<BridgeStatusInfo> GetStatusAsync(BridgeConfig config, CancellationToken ct = default)
{
using var response = await _httpClient.GetAsync(Root(config, "bridge/status"), ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await ReadJsonAsync<BridgeStatusInfo>(response, ct).ConfigureAwait(false) ?? new BridgeStatusInfo(BridgeStatus.Idle);
}
public async Task PostEventAsync(BridgeConfig config, object payload, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/events"), payload, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task SendSessionUpdateAsync(BridgeConfig config, string sessionId, object payload, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/update"), payload, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task<object?> RequestPermissionAsync(BridgeConfig config, string sessionId, object payload, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/permissions/request"), payload, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await ReadJsonAsync<object>(response, ct).ConfigureAwait(false);
}
public async Task<BridgeStatusInfo> RegisterBridgeEnvironmentAsync(BridgeConfig config, BridgeEnvironment environment, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/environments"), environment, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await ReadJsonAsync<BridgeStatusInfo>(response, ct).ConfigureAwait(false) ?? new BridgeStatusInfo(BridgeStatus.Registered);
}
public async Task<WorkItem?> PollForWorkAsync(BridgeConfig config, string environmentId, CancellationToken ct = default)
{
using var response = await _httpClient.GetAsync(Root(config, $"bridge/environments/{Uri.EscapeDataString(environmentId)}/work"), ct).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return null;
}
response.EnsureSuccessStatusCode();
return await ReadJsonAsync<WorkItem>(response, ct).ConfigureAwait(false);
}
public async Task AcknowledgeWorkAsync(BridgeConfig config, string workId, string sessionToken, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}/ack"), new { sessionToken }, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task<SessionHandle> SpawnSessionAsync(BridgeConfig config, SessionSpawnOptions options, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, "bridge/sessions"), options, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await ReadJsonAsync<SessionHandle>(response, ct).ConfigureAwait(false) ?? new SessionHandle(options.Environment.Id, string.Empty);
}
public async Task SendPermissionResponseAsync(BridgeConfig config, string sessionId, PermissionResponse responseBody, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/sessions/{Uri.EscapeDataString(sessionId)}/permissions"), responseBody, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task HeartbeatAsync(BridgeConfig config, string workId, string sessionToken, CancellationToken ct = default)
{
using var response = await SendJsonAsync(HttpMethod.Post, Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}/heartbeat"), new { sessionToken }, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task StopWorkAsync(BridgeConfig config, string workId, CancellationToken ct = default)
{
using var response = await _httpClient.DeleteAsync(Root(config, $"bridge/work/{Uri.EscapeDataString(workId)}"), ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task DeregisterEnvironmentAsync(BridgeConfig config, string environmentId, CancellationToken ct = default)
{
using var response = await _httpClient.DeleteAsync(Root(config, $"bridge/environments/{Uri.EscapeDataString(environmentId)}"), ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
private async Task<HttpResponseMessage> SendJsonAsync(HttpMethod method, Uri uri, object payload, CancellationToken ct)
{
var request = new HttpRequestMessage(method, uri)
{
Content = JsonContent.Create(payload)
};
return await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
}
private static Uri Root(BridgeConfig config, string relativePath)
{
var baseUri = new Uri(config.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
return new Uri(baseUri, relativePath);
}
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return (T?)await response.Content.ReadFromJsonAsync(typeof(T), SourceGenerationContext.Default, ct).ConfigureAwait(false);
}
}

View File

@ -0,0 +1,300 @@
using System.Collections.Concurrent;
using System.Text.Json;
using FreeCode.Core.Enums;
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
namespace FreeCode.Bridge;
public sealed class BridgeService : IBridgeService, IAsyncDisposable
{
private const string SessionTokenKey = "sessionToken";
private const string PromptKey = "prompt";
private const string ModelKey = "model";
private const string AgentTypeKey = "agentType";
private const string WorkingDirectoryKey = "workingDirectory";
private readonly BridgeApiClient _client;
private readonly BridgeConfig _config;
private readonly ConcurrentDictionary<string, SessionHandle> _activeSessions = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _startGate = new(1, 1);
private CancellationTokenSource? _runCts;
private Task? _runLoop;
private BridgeEnvironment? _environment;
private SessionHandle? _session;
private BridgeStatusInfo _status = new(BridgeStatus.Idle);
public BridgeService()
: this(new BridgeApiClient(), new BridgeConfig(Environment.GetEnvironmentVariable("FREECODE_BRIDGE_URL") ?? "http://127.0.0.1:8787"))
{
}
public BridgeService(BridgeApiClient client, BridgeConfig config)
{
_client = client;
_config = config;
}
public BridgeStatus Status => _status.Status;
public async Task<BridgeEnvironment> RegisterEnvironmentAsync()
{
var environment = _environment ??= new BridgeEnvironment(Guid.NewGuid().ToString("N"), Environment.MachineName, SpawnMode.SingleSession, Environment.CurrentDirectory);
_status = await _client.RegisterBridgeEnvironmentAsync(_config, environment).ConfigureAwait(false);
return environment;
}
public async Task StartAsync(CancellationToken ct = default)
{
await _startGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_runLoop is not null)
{
return;
}
_runCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_runLoop = Task.Run(() => RunAsync(_runCts.Token), CancellationToken.None);
}
finally
{
_startGate.Release();
}
}
public async Task StopAsync()
{
if (_runCts is null)
{
return;
}
_runCts.Cancel();
if (_runLoop is not null)
{
try
{
await _runLoop.ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
if (!(_runCts?.IsCancellationRequested ?? false))
{
Console.Error.WriteLine($"Bridge run loop error: {ex.Message}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Bridge run loop error: {ex.Message}");
}
}
_runLoop = null;
_runCts.Dispose();
_runCts = null;
}
public async Task<WorkItem?> PollForWorkAsync(CancellationToken ct)
{
var environment = _environment;
if (environment is null)
{
return null;
}
return await _client.PollForWorkAsync(_config, environment.Id, ct).ConfigureAwait(false);
}
public async Task<SessionHandle> SpawnSessionAsync(SessionSpawnOptions options)
{
_session = await _client.SpawnSessionAsync(_config, options).ConfigureAwait(false);
_activeSessions[_session.SessionId] = _session;
_status = new BridgeStatusInfo(BridgeStatus.Attached);
return _session;
}
public async Task AcknowledgeWorkAsync(string workId, string sessionToken)
{
await _client.AcknowledgeWorkAsync(_config, workId, sessionToken).ConfigureAwait(false);
}
public async Task SendPermissionResponseAsync(string sessionId, PermissionResponse response)
{
await _client.SendPermissionResponseAsync(_config, sessionId, response).ConfigureAwait(false);
}
public async Task HeartbeatAsync(string workId, string sessionToken)
{
await _client.HeartbeatAsync(_config, workId, sessionToken).ConfigureAwait(false);
}
public async Task StopWorkAsync(string workId)
{
await _client.StopWorkAsync(_config, workId).ConfigureAwait(false);
}
public async Task DeregisterEnvironmentAsync()
{
if (_environment is null)
{
return;
}
await _client.DeregisterEnvironmentAsync(_config, _environment.Id).ConfigureAwait(false);
_status = new BridgeStatusInfo(BridgeStatus.Idle);
}
public async Task RunAsync(CancellationToken ct = default)
{
await RegisterEnvironmentAsync().ConfigureAwait(false);
while (!ct.IsCancellationRequested)
{
var work = await PollForWorkAsync(ct).ConfigureAwait(false);
if (work is null)
{
await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
continue;
}
var secret = DecodeWorkSecret(work);
await AcknowledgeWorkAsync(work.Id, secret.SessionToken).ConfigureAwait(false);
var sessionDirectory = PrepareSessionDirectory(work, secret.WorkingDirectory);
var environment = GetSessionEnvironment(sessionDirectory);
var session = await SpawnSessionAsync(new SessionSpawnOptions(environment, secret.Prompt, secret.Model, secret.AgentType)).ConfigureAwait(false);
_activeSessions[work.Id] = session;
_ = MonitorSessionAsync(work, session, secret.SessionToken, ct);
}
}
private async Task MonitorSessionAsync(WorkItem work, SessionHandle session, string sessionToken, CancellationToken ct)
{
try
{
_session = session;
await HeartbeatAsync(work.Id, sessionToken).ConfigureAwait(false);
await _client.SendSessionUpdateAsync(_config, session.SessionId, new { workId = work.Id, status = "running" }, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "cancelled" }).ConfigureAwait(false);
}
catch (Exception ex)
{
await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "failed", error = ex.Message }).ConfigureAwait(false);
}
finally
{
_activeSessions.TryRemove(work.Id, out _);
if (_session?.SessionId == session.SessionId)
{
_session = null;
}
await SafeSendSessionUpdateAsync(session.SessionId, new { workId = work.Id, status = "completed" }).ConfigureAwait(false);
_status = _activeSessions.IsEmpty ? new BridgeStatusInfo(BridgeStatus.Registered) : new BridgeStatusInfo(BridgeStatus.Attached);
}
}
public async Task PollForCommandsAsync(CancellationToken ct = default)
{
while (!ct.IsCancellationRequested)
{
var work = await PollForWorkAsync(ct).ConfigureAwait(false);
if (work is null)
{
await Task.Delay(TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
continue;
}
if (!string.IsNullOrWhiteSpace(work.SessionToken))
{
await AcknowledgeWorkAsync(work.Id, work.SessionToken).ConfigureAwait(false);
}
}
}
public async Task SendResponseAsync(object response)
{
if (_session is not null)
{
await _client.SendSessionUpdateAsync(_config, _session.SessionId, response).ConfigureAwait(false);
}
}
public async Task BroadcastEventAsync(object evt)
{
await _client.PostEventAsync(_config, evt).ConfigureAwait(false);
}
private BridgeEnvironment GetSessionEnvironment(string sessionDirectory)
{
var environment = _environment ?? new BridgeEnvironment(Guid.NewGuid().ToString("N"), Environment.MachineName, SpawnMode.SingleSession, Environment.CurrentDirectory);
return environment with { WorkingDirectory = sessionDirectory };
}
private string PrepareSessionDirectory(WorkItem work, string? workingDirectory)
{
var rootDirectory = string.IsNullOrWhiteSpace(workingDirectory)
? _environment?.WorkingDirectory ?? Environment.CurrentDirectory
: workingDirectory;
var safeWorkId = string.Concat(work.Id.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch));
var sessionDirectory = Path.Combine(rootDirectory, ".freecode-bridge", safeWorkId);
Directory.CreateDirectory(sessionDirectory);
return sessionDirectory;
}
private WorkSecret DecodeWorkSecret(WorkItem work)
{
var sessionToken = TryGetString(work.Payload, SessionTokenKey) ?? work.SessionToken;
if (string.IsNullOrWhiteSpace(sessionToken))
{
throw new InvalidOperationException($"Work item '{work.Id}' does not include a session token.");
}
return new WorkSecret(
sessionToken,
TryGetString(work.Payload, PromptKey),
TryGetString(work.Payload, ModelKey),
TryGetString(work.Payload, AgentTypeKey),
TryGetString(work.Payload, WorkingDirectoryKey));
}
private async Task SafeSendSessionUpdateAsync(string sessionId, object payload)
{
try
{
await _client.SendSessionUpdateAsync(_config, sessionId, payload).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Bridge session update failed: {ex.Message}");
}
}
private static string? TryGetString(JsonElement payload, string propertyName)
{
return payload.ValueKind == JsonValueKind.Object
&& payload.TryGetProperty(propertyName, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
public async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
await DeregisterEnvironmentAsync().ConfigureAwait(false);
}
private sealed record WorkSecret(
string SessionToken,
string? Prompt,
string? Model,
string? AgentType,
string? WorkingDirectory);
}

View File

@ -0,0 +1,8 @@
using FreeCode.Core.Enums;
using FreeCode.Core.Models;
namespace FreeCode.Bridge;
public sealed record BridgeConfig(string BaseUrl, string? ApiKey = null);
public sealed record BridgeStatusInfo(BridgeStatus Status);

View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>FreeCode.Bridge</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FreeCode.Core\FreeCode.Core.csproj" />
<ProjectReference Include="..\FreeCode.Services\FreeCode.Services.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace FreeCode.Bridge;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFreeCodeBridge(this IServiceCollection services)
{
services.AddSingleton<BridgeService>();
services.AddSingleton<IBridgeService>(sp => sp.GetRequiredService<BridgeService>());
return services;
}
}

View File

@ -0,0 +1,17 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using FreeCode.Core.Models;
namespace FreeCode.Bridge;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(BridgeStatusInfo))]
[JsonSerializable(typeof(BridgeEnvironment))]
[JsonSerializable(typeof(WorkItem))]
[JsonSerializable(typeof(SessionHandle))]
[JsonSerializable(typeof(SessionSpawnOptions))]
[JsonSerializable(typeof(PermissionResponse))]
[JsonSerializable(typeof(JsonElement))]
internal sealed partial class SourceGenerationContext : JsonSerializerContext
{
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class AddDirCommand : CommandBase
{
public override string Name => "add-dir";
public override string Description => "Add a working directory.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class AdvisorCommand : CommandBase
{
public override string Name => "advisor";
public override string Description => "Get advice.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class AgentsCommand : CommandBase
{
public override string Name => "agents";
public override string Description => "List available agents.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class AntTraceCommand : CommandBase
{
public override string Name => "ant-trace";
public override string Description => "Trace operations.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class AssistantCommand : CommandBase
{
public override string Name => "assistant";
public override string Description => "Manage assistant profiles.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BranchCommand : CommandBase
{
public override string Name => "branch";
public override string Description => "Manage git branches.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BreakCacheCommand : CommandBase
{
public override string Name => "break-cache";
public override string Description => "Break the prompt cache.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BridgeKickCommand : CommandBase
{
public override string Name => "bridge-kick";
public override string Description => "Kick the bridge session.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,57 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FreeCode.Core.Enums;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BriefCommand : CommandBase
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public override string Name => "brief";
public override string Description => "Toggle brief output mode.";
public override CommandCategory Category => CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
{
var config = LoadConfig();
var currentBrief = config["briefMode"]?.GetValue<bool>() ?? false;
var newBrief = !currentBrief;
config["briefMode"] = newBrief;
SaveConfig(config);
return Task.FromResult(new CommandResult(true, $"Brief mode: {(newBrief ? "ON" : "OFF")}"));
}
private static JsonObject LoadConfig()
{
var path = GetConfigPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (!File.Exists(path))
{
return [];
}
try
{
return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? [];
}
catch (JsonException)
{
return [];
}
}
private static void SaveConfig(JsonObject config)
{
var path = GetConfigPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, config.ToJsonString(JsonOptions));
}
private static string GetConfigPath()
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json");
}

View File

@ -0,0 +1,15 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BtwCommand : CommandBase
{
public override string Name => "btw";
public override string Description => "Buddy companion interaction.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override bool IsEnabled() => true;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class BughunterCommand : CommandBase
{
public override string Name => "bughunter";
public override string Description => "Bug hunting mode.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ChromeCommand : CommandBase
{
public override string Name => "chrome";
public override string Description => "Chrome extension integration.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ClearCommand : CommandBase
{
public override string Name => "clear";
public override string Description => "Clear the current terminal view.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> Task.FromResult(new CommandResult(true));
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ColorCommand : CommandBase
{
public override string Name => "color";
public override string Description => "Set the color scheme.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,16 @@
using FreeCode.Core.Enums;
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public abstract class CommandBase : ICommand
{
public abstract string Name { get; }
public virtual string[]? Aliases => null;
public abstract string Description { get; }
public abstract CommandCategory Category { get; }
public virtual CommandAvailability Availability => CommandAvailability.Always;
public virtual bool IsEnabled() => true;
public abstract Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
using FreeCode.Core.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace FreeCode.Commands;
public sealed class CommandRegistry(IServiceProvider serviceProvider) : ICommandRegistry
{
private readonly object _gate = new();
private IReadOnlyList<ICommand>? _cachedCommands;
public Task<IReadOnlyList<ICommand>> GetCommandsAsync()
=> Task.FromResult(GetOrCreateCommands());
public Task<IReadOnlyList<ICommand>> GetEnabledCommandsAsync()
{
var commands = GetOrCreateCommands();
var authService = serviceProvider.GetService(typeof(IAuthService)) as IAuthService;
var enabled = new List<ICommand>(commands.Count + 4);
foreach (var command in commands)
{
if (!command.IsEnabled())
{
continue;
}
if (!IsAvailable(command, authService))
{
continue;
}
enabled.Add(command);
}
foreach (var pluginCommand in GetPluginCommands())
{
if (!pluginCommand.IsEnabled())
{
continue;
}
if (!IsAvailable(pluginCommand, authService))
{
continue;
}
enabled.Add(pluginCommand);
}
enabled.Sort(static (left, right) =>
{
var categoryComparison = Comparer<FreeCode.Core.Enums.CommandCategory>.Default.Compare(left.Category, right.Category);
return categoryComparison != 0
? categoryComparison
: StringComparer.OrdinalIgnoreCase.Compare(left.Name, right.Name);
});
return Task.FromResult<IReadOnlyList<ICommand>>(enabled);
}
private IReadOnlyList<ICommand> GetOrCreateCommands()
{
lock (_gate)
{
if (_cachedCommands is not null)
{
return _cachedCommands;
}
var commands = serviceProvider.GetServices<ICommand>().ToArray();
_cachedCommands = commands
.OrderBy(command => command.Category)
.ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return _cachedCommands;
}
}
private static bool IsAvailable(ICommand command, IAuthService? authService)
=> command.Availability switch
{
FreeCode.Core.Enums.CommandAvailability.Always => true,
FreeCode.Core.Enums.CommandAvailability.RequiresAuth => authService?.IsAuthenticated ?? false,
FreeCode.Core.Enums.CommandAvailability.ClaudeAiOnly => authService?.IsClaudeAiUser ?? false,
FreeCode.Core.Enums.CommandAvailability.InternalOnly => authService?.IsInternalUser ?? false,
_ => false,
};
private IReadOnlyList<ICommand> GetPluginCommands()
{
var pluginManager = serviceProvider.GetService(typeof(IPluginManager));
if (pluginManager is FreeCode.Plugins.PluginManager concreteManager)
{
return concreteManager.GetPluginCommands().OfType<ICommand>().ToArray();
}
return [];
}
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CommitCommand : CommandBase
{
public override string Name => "commit";
public override string Description => "Generate and create a git commit.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CommitPushPrCommand : CommandBase
{
public override string Name => "commit-push-pr";
public override string[]? Aliases => new[] { "cpp" };
public override string Description => "Commit, push, and open a pull request.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CompactCommand : CommandBase
{
public override string Name => "compact";
public override string Description => "Compact the conversation context.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ConfigCommand : CommandBase
{
public override string Name => "config";
public override string Description => "Get, set, or list configuration.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.LocalDialog;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ContextCommand : CommandBase
{
public override string Name => "context";
public override string Description => "Show current context information.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CopyCommand : CommandBase
{
public override string Name => "copy";
public override string Description => "Copy the last response to the clipboard.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CostCommand : CommandBase
{
public override string Name => "cost";
public override string Description => "Show token cost estimates.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class CtxVizCommand : CommandBase
{
public override string Name => "ctx-viz";
public override string Description => "Visualize the context.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class DebugToolCallCommand : CommandBase
{
public override string Name => "debug-tool-call";
public override string Description => "Debug tool calls.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class DesktopCommand : CommandBase
{
public override string Name => "desktop";
public override string Description => "Desktop integration.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class DiffCommand : CommandBase
{
public override string Name => "diff";
public override string Description => "Show the git diff.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class DoctorCommand : CommandBase
{
public override string Name => "doctor";
public override string Description => "Run diagnostics.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class EffortCommand : CommandBase
{
public override string Name => "effort";
public override string Description => "Set the effort level.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,28 @@
using FreeCode.Core.Enums;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class EnvCommand : CommandBase
{
public override string Name => "env";
public override string Description => "Show environment variables.";
public override CommandCategory Category => CommandCategory.Local;
public override CommandAvailability Availability => CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
{
var variables = new List<string>();
foreach (var entry in Environment.GetEnvironmentVariables().Cast<System.Collections.DictionaryEntry>().OrderBy(e => (string)e.Key, StringComparer.OrdinalIgnoreCase))
{
if (entry.Key.ToString()?.StartsWith("FREE_CODE", StringComparison.OrdinalIgnoreCase) == true)
{
variables.Add($"{entry.Key}={entry.Value}");
}
}
return variables.Count == 0
? Task.FromResult(new CommandResult(true, "No FREE_CODE environment variables set."))
: Task.FromResult(new CommandResult(true, string.Join(Environment.NewLine, variables)));
}
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ExitCommand : CommandBase
{
public override string Name => "exit";
public override string[]? Aliases => ["quit"];
public override string Description => "Exit the application.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> Task.FromResult(new CommandResult(true, "Exit requested."));
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ExportCommand : CommandBase
{
public override string Name => "export";
public override string Description => "Export the conversation to a file.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class ExtraUsageCommand : CommandBase
{
public override string Name => "extra-usage";
public override string Description => "Show detailed usage information.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.RequiresAuth;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class FastCommand : CommandBase
{
public override string Name => "fast";
public override string Description => "Toggle fast mode.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class FeedbackCommand : CommandBase
{
public override string Name => "feedback";
public override string Description => "Submit feedback.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class FilesCommand : CommandBase
{
public override string Name => "files";
public override string Description => "Manage project files context.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>FreeCode.Commands</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FreeCode.Core\FreeCode.Core.csproj" />
<ProjectReference Include="..\FreeCode.Engine\FreeCode.Engine.csproj" />
<ProjectReference Include="..\FreeCode.Plugins\FreeCode.Plugins.csproj" />
<ProjectReference Include="..\FreeCode.State\FreeCode.State.csproj" />
<ProjectReference Include="..\FreeCode.Skills\FreeCode.Skills.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class GoodClaudeCommand : CommandBase
{
public override string Name => "good-claude";
public override string Description => "Rate the response.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,14 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class HeapdumpCommand : CommandBase
{
public override string Name => "heapdump";
public override string Description => "Trigger a heap dump.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override FreeCode.Core.Enums.CommandAvailability Availability => FreeCode.Core.Enums.CommandAvailability.InternalOnly;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,34 @@
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class HelpCommand(IServiceProvider services) : CommandBase
{
public override string Name => "help";
public override string Description => "Show available commands.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Prompt;
public override async Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
{
var commandRegistry = services.GetService(typeof(ICommandRegistry)) as ICommandRegistry;
if (commandRegistry is null)
{
return new CommandResult(false, "Command registry is unavailable.");
}
var commands = await commandRegistry.GetEnabledCommandsAsync().ConfigureAwait(false);
var lines = new List<string>(commands.Count + 1) { "Available commands:" };
foreach (var command in commands.OrderBy(command => command.Category).ThenBy(command => command.Name, StringComparer.OrdinalIgnoreCase))
{
var aliasText = command.Aliases is { Length: > 0 }
? $" ({string.Join(", ", command.Aliases)})"
: string.Empty;
lines.Add($"/{command.Name}{aliasText} - {command.Description}");
}
return new CommandResult(true, string.Join(Environment.NewLine, lines));
}
}

View File

@ -0,0 +1,13 @@
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class HooksCommand : CommandBase
{
public override string Name => "hooks";
public override string Description => "Manage lifecycle hooks.";
public override FreeCode.Core.Enums.CommandCategory Category => FreeCode.Core.Enums.CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
=> CommandExecutionHelper.ExecuteAsync(Name, context, args, ct);
}

View File

@ -0,0 +1,236 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using FreeCode.Core.Enums;
using FreeCode.Core.Interfaces;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class IdeCommand(IServiceProvider services) : CommandBase
{
private readonly IServiceProvider _services = services;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public override string Name => "ide";
public override string Description => "Manage IDE integrations and show status.";
public override CommandCategory Category => CommandCategory.Local;
public override Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
{
var tokens = Tokenize(args);
var subcommand = tokens.Count == 0 ? "status" : tokens[0].ToLowerInvariant();
return subcommand switch
{
"status" => Task.FromResult(GetStatus(context)),
"connect" => ConnectAsync(context, tokens, ct),
"disconnect" => DisconnectAsync(context, ct),
"install" => InstallAsync(context, tokens, ct),
_ => Task.FromResult(new CommandResult(false, "Usage: /ide [connect <vscode|jetbrains> [url]|disconnect|status|install <vscode|jetbrains>]"))
};
}
private static CommandResult GetStatus(CommandContext context)
{
var featureFlags = context.Services.GetService(typeof(IFeatureFlagService)) as IFeatureFlagService;
var mcpManager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager;
var ideConnection = mcpManager?.GetConnections()
.FirstOrDefault(connection => string.Equals(connection.Name, "ide", StringComparison.OrdinalIgnoreCase));
var lines = new List<string>
{
$"IDE integration feature: {(featureFlags?.IsEnabled("IDE_INTEGRATION") == true ? "enabled" : "disabled")}",
$"Configured connection: {(ideConnection is null ? "none" : DescribeConnection(ideConnection))}"
};
if (ideConnection?.Config is SseIdeServerConfig sse)
{
lines.Add($"URL: {sse.Url}");
lines.Add($"IDE: {sse.IdeName}");
}
else if (ideConnection?.Config is WsIdeServerConfig ws)
{
lines.Add($"URL: {ws.Url}");
lines.Add($"IDE: {ws.IdeName}");
}
else
{
lines.Add("Supported IDEs: VS Code, JetBrains");
}
return new CommandResult(true, string.Join(Environment.NewLine, lines));
}
private static async Task<CommandResult> ConnectAsync(CommandContext context, IReadOnlyList<string> tokens, CancellationToken ct)
{
if (tokens.Count < 2)
{
return new CommandResult(false, "Usage: /ide connect <vscode|jetbrains> [url]");
}
var ide = NormalizeIde(tokens[1]);
if (ide is null)
{
return new CommandResult(false, "Supported IDEs: vscode, jetbrains");
}
var url = tokens.Count > 2 ? tokens[2] : GetDefaultIdeUrl(ide);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != "ws" && uri.Scheme != "wss"))
{
return new CommandResult(false, $"Invalid IDE endpoint: {url}");
}
var config = LoadConfig();
var mcpServers = GetObject(config, "mcpServers");
mcpServers["ide"] = new JsonObject
{
["type"] = uri.Scheme is "ws" or "wss" ? "ws-ide" : "sse-ide",
["url"] = url,
["ideName"] = ide,
["scope"] = "User"
};
config["mcpServers"] = mcpServers;
SaveConfig(config);
var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager;
if (manager is not null)
{
await manager.ReloadAsync().ConfigureAwait(false);
}
return new CommandResult(true, $"Connected IDE integration for {ide} at {url}.");
}
private static async Task<CommandResult> DisconnectAsync(CommandContext context, CancellationToken ct)
{
var config = LoadConfig();
var mcpServers = GetObject(config, "mcpServers");
var removed = mcpServers.Remove("ide");
config["mcpServers"] = mcpServers;
SaveConfig(config);
var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager;
if (manager is not null)
{
try
{
await manager.DisconnectServerAsync("ide").ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: Failed to disconnect IDE integration: {ex.Message}");
}
await manager.ReloadAsync().ConfigureAwait(false);
}
return new CommandResult(true, removed ? "Disconnected IDE integration." : "No IDE integration was configured.");
}
private static async Task<CommandResult> InstallAsync(CommandContext context, IReadOnlyList<string> tokens, CancellationToken ct)
{
if (tokens.Count < 2)
{
return new CommandResult(false, "Usage: /ide install <vscode|jetbrains>");
}
var ide = NormalizeIde(tokens[1]);
if (ide is null)
{
return new CommandResult(false, "Supported IDEs: vscode, jetbrains");
}
var displayName = ide == "vscode" ? "VS Code" : "JetBrains";
var config = LoadConfig();
var mcpServers = GetObject(config, "mcpServers");
mcpServers["ide"] = new JsonObject
{
["command"] = "free-code-ide-extension",
["args"] = new JsonArray(ide, GetDefaultIdeUrl(ide)),
["env"] = new JsonObject
{
["FREE_CODE_IDE"] = ide,
["FREE_CODE_IDE_DISPLAY_NAME"] = displayName,
["FREE_CODE_IDE_URL"] = GetDefaultIdeUrl(ide)
},
["scope"] = "User"
};
config["mcpServers"] = mcpServers;
SaveConfig(config);
var manager = context.Services.GetService(typeof(IMcpClientManager)) as IMcpClientManager;
if (manager is not null)
{
await manager.ReloadAsync().ConfigureAwait(false);
}
return new CommandResult(true, $"Installed IDE integration for {displayName} in {GetConfigPath()}. Next step: install or enable the {displayName} extension that provides the 'free-code-ide-extension' MCP bridge, then run /ide connect {ide} if you want to override the default endpoint.");
}
private static string DescribeConnection(MCPServerConnection connection)
{
var status = connection.IsConnected ? "connected"
: connection.IsPending ? "pending"
: connection.IsFailed ? "failed"
: connection.NeedsAuth ? "needs authentication"
: connection.IsDisabled ? "disabled"
: connection.ConnectionType;
return $"{connection.Name} ({status})";
}
private static string? NormalizeIde(string value)
=> value.Trim().ToLowerInvariant() switch
{
"vscode" or "vs-code" or "vs_code" or "code" => "vscode",
"jetbrains" or "jb" or "intellij" or "rider" => "jetbrains",
_ => null
};
private static string GetDefaultIdeUrl(string ide)
=> ide == "jetbrains"
? "ws://127.0.0.1:63342/free-code"
: "http://127.0.0.1:7777/sse";
private static JsonObject LoadConfig()
{
var path = GetConfigPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (!File.Exists(path))
{
return [];
}
return JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? [];
}
private static void SaveConfig(JsonObject config)
{
var path = GetConfigPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, config.ToJsonString(JsonOptions));
}
private static JsonObject GetObject(JsonObject parent, string name)
{
if (parent[name] is JsonObject value)
{
return value;
}
var created = new JsonObject();
parent[name] = created;
return created;
}
private static List<string> Tokenize(string? args)
=> string.IsNullOrWhiteSpace(args)
? []
: args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
private static string GetConfigPath()
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".free-code", "config.json");
}

View File

@ -0,0 +1,61 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using FreeCode.Core.Enums;
using FreeCode.Core.Models;
namespace FreeCode.Commands;
public sealed class InitCommand(IServiceProvider services) : CommandBase
{
private readonly IServiceProvider _services = services;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public override string Name => "init";
public override string Description => "Initialize a new project configuration.";
public override CommandCategory Category => CommandCategory.Local;
public override async Task<CommandResult> ExecuteAsync(CommandContext context, string? args = null, CancellationToken ct = default)
{
var configDirectory = Path.Combine(context.WorkingDirectory, ".free-code");
var configPath = Path.Combine(configDirectory, "config.json");
Directory.CreateDirectory(configDirectory);
JsonObject config;
if (File.Exists(configPath))
{
config = JsonNode.Parse(await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false)) as JsonObject ?? [];
}
else
{
config = [];
}
config["projectName"] ??= Path.GetFileName(context.WorkingDirectory);
config["workingDirectory"] = context.WorkingDirectory;
config["createdAt"] ??= DateTimeOffset.UtcNow.ToString("O");
config["commands"] ??= new JsonObject();
config["contextFiles"] ??= new JsonArray();
config["mcpServers"] ??= new JsonObject();
config["voiceEnabled"] ??= false;
config["voiceMode"] ??= "push-to-talk";
await File.WriteAllTextAsync(configPath, config.ToJsonString(JsonOptions), ct).ConfigureAwait(false);
var readmePath = Path.Combine(configDirectory, "README.md");
if (!File.Exists(readmePath))
{
var markdown = new StringBuilder()
.AppendLine("# free-code project config")
.AppendLine()
.AppendLine($"Initialized for `{Path.GetFileName(context.WorkingDirectory)}`.")
.AppendLine()
.AppendLine("This directory stores project-specific configuration for the .NET CLI port.")
.ToString();
await File.WriteAllTextAsync(readmePath, markdown, ct).ConfigureAwait(false);
}
return new CommandResult(true, $"Initialized project configuration in {configDirectory}.");
}
}

Some files were not shown because too many files have changed in this diff Show More