diff --git a/README.md b/README.md
index 444c87d3..fb1ab8ea 100644
--- a/README.md
+++ b/README.md
@@ -40,10 +40,83 @@ dotnet build JoinCode.slnx -c Release
| 环境变量 | 必填 | 说明 | 示例 |
|----------|------|------|------|
| `JCC_API_KEY` | 是 | API 密钥 | `sk-xxxxxxxx` |
-| `JCC_PROVIDER` | 否 | Provider 名称(默认 openai) | `openai` / `anthropic` / `azure` / `deepseek` |
-| `JCC_MODEL_ID` | 否 | 模型 ID(默认 gpt-4o) | `gpt-4o` / `claude-3-5-sonnet-20241022` |
+| `JCC_PROVIDER` | 否 | Provider 名称(默认 deepseek) | `deepseek` / `openai` / `anthropic` / `azure` |
+| `JCC_MODEL_ID` | 否 | 模型 ID(默认 deepseek-v4-flash) | `deepseek-v4-flash` / `gpt-4o` / `claude-3-5-sonnet-20241022` |
| `JCC_ENDPOINT` | 否 | API 端点(默认使用 Provider 内置地址) | `http://localhost:9901` |
+### 使用 DeepSeek
+
+DeepSeek 是默认 Provider,兼容 OpenAI API 协议,开箱即用。只需设置 API Key 即可启动。
+
+#### 方式一:环境变量(推荐)
+
+```powershell
+# 设置 API Key(优先使用 DeepSeek 专属变量)
+$env:DEEPSEEK_API_KEY = "sk-your-deepseek-api-key"
+
+# 设置 Provider 为 DeepSeek
+$env:JCC_PROVIDER = "deepseek"
+
+# 可选:指定模型(默认 deepseek-v4-flash)
+$env:JCC_MODEL_ID = "deepseek-v4-pro"
+
+# 启动
+jcc --trust
+```
+
+> **回退机制**:如果未设置 `DEEPSEEK_API_KEY`,会自动回退读取 `OPENAI_API_KEY`。
+
+#### 方式二:配置文件
+
+将 API Key 存储到 `~/.jcc/auth.json`,避免每次设置环境变量:
+
+```json
+{
+ "deepseek": "sk-your-deepseek-api-key"
+}
+```
+
+将 Provider 设置写入 `~/.jcc/settings.json`:
+
+```json
+{
+ "provider": "deepseek",
+ "modelId": "deepseek-v4-flash"
+}
+```
+
+#### 方式三:项目级配置
+
+在项目根目录创建 `.env/api.json`(适合团队共享默认配置):
+
+```json
+{
+ "env": {
+ "DEEPSEEK_API_KEY": "sk-your-deepseek-api-key",
+ "JCC_PROVIDER": "deepseek",
+ "JCC_MODEL_ID": "deepseek-v4-flash"
+ }
+}
+```
+
+#### 可用模型
+
+| 模型 ID | 别名 | 上下文 | 说明 |
+|---------|------|--------|------|
+| `deepseek-v4-flash` | `flash`、`v4`、`chat` | 1M | 快速模型,支持思考模式(默认) |
+| `deepseek-v4-pro` | `pro` | 1M | 旗舰模型,支持思考模式 |
+
+交互模式下可通过 `/model flash` 或 `/model pro` 快速切换模型。
+
+#### API Key 优先级
+
+从低到高:
+
+1. `~/.jcc/auth.json` 中的 `"deepseek"` 字段
+2. `JCC_API_KEY` 环境变量
+3. `DEEPSEEK_API_KEY` 环境变量(最高优先级)
+4. 回退:`OPENAI_API_KEY` 环境变量
+
### 运行
```powershell
diff --git a/components/02-brain/Brain/src/Context/Compact/Services/ExtractMemoriesCallback.cs b/components/02-brain/Brain/src/Context/Compact/Services/ExtractMemoriesCallback.cs
index 36914cbb..a665adfc 100644
--- a/components/02-brain/Brain/src/Context/Compact/Services/ExtractMemoriesCallback.cs
+++ b/components/02-brain/Brain/src/Context/Compact/Services/ExtractMemoriesCallback.cs
@@ -93,7 +93,7 @@ public async Task OnPostSamplingAsync(PostSamplingContext context)
private string GetMemoryDirectory()
{
var cwd = _fileSystem.GetCurrentDirectory();
- return Path.Combine(cwd, ".jcc", "memory");
+ return Path.Combine(cwd, AppDataConstants.AppDataFolder, "memory");
}
private string FormatMemoryManifest(string memoryDir)
diff --git a/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryCompactService.cs b/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryCompactService.cs
index 3f2a4b14..95fd3b49 100644
--- a/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryCompactService.cs
+++ b/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryCompactService.cs
@@ -4,7 +4,7 @@ namespace Core.Context.Compact;
[Register]
public sealed partial class SessionMemoryCompactService : ISessionMemoryCompactService
{
- private const string SessionMemorySubdir = ".jcc";
+ private static readonly string SessionMemorySubdir = AppDataConstants.AppDataFolder;
private const string SessionMemoryFileName = "session-memory.md";
private readonly SessionMemoryCompactConfig _config;
diff --git a/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryExtractionService.cs b/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryExtractionService.cs
index 114617d4..636884f1 100644
--- a/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryExtractionService.cs
+++ b/components/02-brain/Brain/src/Context/Compact/Services/SessionMemoryExtractionService.cs
@@ -75,7 +75,7 @@ public bool ShouldExtract(int currentTokenCount, int toolCallsSinceLastUpdate)
public string GetMemoryFilePath()
{
var cwd = _fileSystem.GetCurrentDirectory();
- return Path.Combine(cwd, ".jcc", "session-memory.md");
+ return Path.Combine(cwd, AppDataConstants.AppDataFolder, "session-memory.md");
}
///
diff --git a/components/03-hands/Hands/src/Shell/Providers/BashShellProvider.cs b/components/03-hands/Hands/src/Shell/Providers/BashShellProvider.cs
index 43de6adc..5d33cfc7 100644
--- a/components/03-hands/Hands/src/Shell/Providers/BashShellProvider.cs
+++ b/components/03-hands/Hands/src/Shell/Providers/BashShellProvider.cs
@@ -28,7 +28,7 @@ public sealed class BashShellProvider : ShellProviderBase
///
private static readonly string SnapshotDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- ".jcc", "shell-snapshots");
+ AppDataConstants.AppDataFolder, "shell-snapshots");
public BashShellProvider(IFileSystem fs, string? shellPath = null, ILogger? logger = null)
: base(fs, shellPath, logger)
diff --git a/components/03-hands/Hands/src/Skills/Discovery/SkillDiscoveryOptions.cs b/components/03-hands/Hands/src/Skills/Discovery/SkillDiscoveryOptions.cs
index cbf36b6e..2ddb3026 100644
--- a/components/03-hands/Hands/src/Skills/Discovery/SkillDiscoveryOptions.cs
+++ b/components/03-hands/Hands/src/Skills/Discovery/SkillDiscoveryOptions.cs
@@ -6,7 +6,7 @@ namespace Core.Skills.Discovery;
public sealed partial class SkillDiscoveryOptions
{
public string SkillsDirectory { get; init; } = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
"skills");
diff --git a/components/03-hands/Hands/src/Skills/Services/Skill/SkillService.cs b/components/03-hands/Hands/src/Skills/Services/Skill/SkillService.cs
index ad6a0fa4..377d9615 100644
--- a/components/03-hands/Hands/src/Skills/Services/Skill/SkillService.cs
+++ b/components/03-hands/Hands/src/Skills/Services/Skill/SkillService.cs
@@ -5,7 +5,7 @@ namespace Core.Skills;
[Register]
public sealed record SkillOptions
{
- public string SkillsDirectory { get; init; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDataConstants.AppDataFolder, "skills");
+ public string SkillsDirectory { get; init; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder, "skills");
public TimeSpan CacheExpiration { get; init; } = TimeSpan.FromMinutes(5);
public SkillOptions() { }
@@ -14,7 +14,7 @@ public SkillOptions(WorkflowConfig? config)
{
SkillsDirectory = config is not null && !string.IsNullOrEmpty(config.SkillsDirectory)
? config.SkillsDirectory
- : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDataConstants.AppDataFolder, "skills");
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder, "skills");
}
public static SkillOptions FromConfig(WorkflowConfig? config) => new(config);
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigJsonContext.cs b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigJsonContext.cs
index 7cc085eb..4b8873a5 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigJsonContext.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigJsonContext.cs
@@ -1,7 +1,7 @@
namespace Core.Configuration;
-[JsonSourceGenerationOptions(WriteIndented = false)]
+[JsonSourceGenerationOptions(WriteIndented = false, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true)]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(SettingsJson))]
@@ -14,7 +14,7 @@ namespace Core.Configuration;
[JsonSerializable(typeof(StatusLineSettings))]
public partial class ConfigJsonContext : JsonSerializerContext;
-[JsonSourceGenerationOptions(WriteIndented = true)]
+[JsonSourceGenerationOptions(WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true)]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(SettingsJson))]
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigLoader.cs b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigLoader.cs
index 5f878a7e..54e80863 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigLoader.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/ConfigLoader.cs
@@ -113,7 +113,7 @@ public async Task LoadConfigAsync(IFileSystem fs, CancellationTo
public static async Task LoadSettingsJsonAsync(IFileSystem fs, CancellationToken cancellationToken = default)
{
var settingsPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.SettingsFileName);
@@ -137,7 +137,7 @@ public async Task LoadConfigAsync(IFileSystem fs, CancellationTo
public static async Task SaveSettingsJsonAsync(SettingsJson settings, IFileSystem fs, CancellationToken cancellationToken = default)
{
var settingsPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.SettingsFileName);
@@ -248,7 +248,7 @@ public static async Task SaveApiKeyToJccAsync(string provider, string apiKey, IF
public static async Task LoadSettingFromSettingsJsonAsync(string key, IFileSystem fs, CancellationToken cancellationToken = default)
{
var settingsPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.SettingsFileName);
@@ -276,7 +276,7 @@ public static async Task SaveApiKeyToJccAsync(string provider, string apiKey, IF
public static string? LoadSettingFromSettingsJson(string key, IFileSystem fs)
{
var settingsPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.SettingsFileName);
@@ -325,7 +325,7 @@ public static async Task SaveApiKeyToJccAsync(string provider, string apiKey, IF
public static async Task SaveSettingToSettingsJsonAsync(string key, string? value, IFileSystem fs, CancellationToken cancellationToken = default)
{
var settingsPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.SettingsFileName);
@@ -379,7 +379,7 @@ public static async Task SaveSettingToSettingsJsonAsync(string key, string? valu
public static async Task LoadSettingFromGlobalConfigAsync(string key, IFileSystem fs, CancellationToken cancellationToken = default)
{
var globalPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.GlobalConfigFileName);
@@ -417,7 +417,7 @@ public static async Task SaveSettingToSettingsJsonAsync(string key, string? valu
public static async Task SaveSettingToGlobalConfigAsync(string key, string? value, IFileSystem fs, CancellationToken cancellationToken = default)
{
var globalPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.GlobalConfigFileName);
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/SettingsLoader.cs b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/SettingsLoader.cs
index 09a4c118..1688e707 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/SettingsLoader.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Core/Loading/SettingsLoader.cs
@@ -148,18 +148,17 @@ public static string GetUserSettingsPath()
return Path.Combine(appDataFolder, settingsFileName);
return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
appDataFolder,
settingsFileName);
}
///
/// 获取项目共享设置路径: {projectDir}/.jcc/settings.json
- /// 项目设置始终使用 .jcc 相对目录名,不受 AppDataFolder 绝对路径覆盖影响
+ /// 项目级目录始终使用相对目录名,当 AppDataFolder 为绝对路径(测试隔离)时回退到 .jcc
///
public static string GetProjectSettingsPath(string projectDir)
{
- // 项目设置固定使用 .jcc 目录名,不跟随 AppDataFolder 覆盖
var folderName = Path.IsPathRooted(AppDataConstants.AppDataFolder) ? ".jcc" : AppDataConstants.AppDataFolder;
return Path.Combine(projectDir, folderName, "settings.json");
}
@@ -182,7 +181,7 @@ public static string GetManagedSettingsPath()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
- "jcc",
+ AppDataConstants.AppDataFolder,
"managed-settings.json");
}
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Core/Mapping/SettingsJson.cs b/components/04-guard/Guard/src/Configuration/Configuration/Core/Mapping/SettingsJson.cs
index 4b6ceb57..a49ae095 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Core/Mapping/SettingsJson.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Core/Mapping/SettingsJson.cs
@@ -221,6 +221,13 @@ public SettingsJson() { }
[SettingsProperty(SettingsMergeStrategy.Override, SkipKeyAccess = true)]
public WorktreeSettings? Worktree { get; init; }
+ ///
+ /// 活跃 Worktree 会话 — 对齐 TS 版 activeWorktreeSession,持久化到 settings.local.json
+ ///
+ [JsonPropertyName("activeWorktreeSession")]
+ [SettingsProperty(SettingsMergeStrategy.Override, SkipKeyAccess = true)]
+ public ActiveWorktreeSessionJson? ActiveWorktreeSession { get; init; }
+
///
/// 状态栏配置 — 对齐 TS 版 statusLine
///
@@ -407,6 +414,42 @@ public sealed class WorktreeSettings
public List? SparsePaths { get; init; }
}
+///
+/// 活跃 Worktree 会话 — 对齐 TS 版 activeWorktreeSession,持久化到 settings.local.json
+///
+public sealed class ActiveWorktreeSessionJson
+{
+ [JsonPropertyName("originalCwd")]
+ public string? OriginalCwd { get; init; }
+
+ [JsonPropertyName("worktreePath")]
+ public string? WorktreePath { get; init; }
+
+ [JsonPropertyName("worktreeName")]
+ public string? WorktreeName { get; init; }
+
+ [JsonPropertyName("worktreeBranch")]
+ public string? WorktreeBranch { get; init; }
+
+ [JsonPropertyName("originalBranch")]
+ public string? OriginalBranch { get; init; }
+
+ [JsonPropertyName("originalHeadCommit")]
+ public string? OriginalHeadCommit { get; init; }
+
+ [JsonPropertyName("sessionId")]
+ public string? SessionId { get; init; }
+
+ [JsonPropertyName("hookBased")]
+ public bool? HookBased { get; init; }
+
+ [JsonPropertyName("creationDurationMs")]
+ public long? CreationDurationMs { get; init; }
+
+ [JsonPropertyName("usedSparsePaths")]
+ public bool? UsedSparsePaths { get; init; }
+}
+
///
/// 状态栏配置 — 对齐 TS 版 statusLine
///
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Rules/CustomCommandLoader.cs b/components/04-guard/Guard/src/Configuration/Configuration/Rules/CustomCommandLoader.cs
index 69555938..400fba73 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Rules/CustomCommandLoader.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Rules/CustomCommandLoader.cs
@@ -48,7 +48,7 @@ public async Task> LoadProjectCommandsAsync(string workingDi
public async Task> LoadUserCommandsAsync(CancellationToken cancellationToken = default)
{
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// 并行扫描所有用户命令目录
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Rules/ExternalRulesLoader.cs b/components/04-guard/Guard/src/Configuration/Configuration/Rules/ExternalRulesLoader.cs
index b3e0e5f0..cde145f3 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Rules/ExternalRulesLoader.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Rules/ExternalRulesLoader.cs
@@ -48,7 +48,7 @@ public async Task> LoadProjectRulesAsync(string workingDirectory,
currentDirPath = _fs.GetParentPath(currentDirPath);
}
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// 并行扫描所有用户规则目录
var userDirTasks = new List>>();
foreach (var rulesDir in UserRulesDirs)
diff --git a/components/04-guard/Guard/src/Configuration/Configuration/Rules/ProjectRulesLoader.cs b/components/04-guard/Guard/src/Configuration/Configuration/Rules/ProjectRulesLoader.cs
index ea40f026..e391afc2 100644
--- a/components/04-guard/Guard/src/Configuration/Configuration/Rules/ProjectRulesLoader.cs
+++ b/components/04-guard/Guard/src/Configuration/Configuration/Rules/ProjectRulesLoader.cs
@@ -91,7 +91,7 @@ public ProjectRulesLoader(
currentDirPath = _fs.GetParentPath(currentDirPath);
}
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// 并行读取用户规则文件
var userReadTasks = new List>();
@@ -227,7 +227,7 @@ public bool HasRulesFile(string? workingDirectory = null) {
currentDirPath = _fs.GetParentPath(currentDirPath);
}
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
foreach (var relativePath in UserRulesFilePaths) {
var fullPath = Path.Combine(appDataRoot, relativePath);
if (_fs.FileExists(fullPath)) {
@@ -270,7 +270,7 @@ public bool HasRulesFile(string? workingDirectory = null) {
currentDirPath = _fs.GetParentPath(currentDirPath);
}
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
foreach (var relativePath in UserRulesFilePaths) {
var fullPath = Path.Combine(appDataRoot, relativePath);
if (_fs.FileExists(fullPath)) {
diff --git a/components/04-guard/Guard/src/Configuration/Services/ConfigurationService.cs b/components/04-guard/Guard/src/Configuration/Services/ConfigurationService.cs
index c9bdb5ef..ab47da46 100644
--- a/components/04-guard/Guard/src/Configuration/Services/ConfigurationService.cs
+++ b/components/04-guard/Guard/src/Configuration/Services/ConfigurationService.cs
@@ -87,8 +87,8 @@ public async Task SetAsync(string key, string value, SettingSource source,
{
// 对齐 TS markInternalWrite — 写入前标记内部写,防止 FileSystemWatcher 回声
var targetPath = source == SettingSource.GlobalConfig
- ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDataConstants.AppDataFolder, AppDataConstants.GlobalConfigFileName)
- : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDataConstants.AppDataFolder, AppDataConstants.SettingsFileName);
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder, AppDataConstants.GlobalConfigFileName)
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder, AppDataConstants.SettingsFileName);
_configChangeNotifier?.MarkInternalWrite(targetPath);
if (source == SettingSource.GlobalConfig)
@@ -120,7 +120,7 @@ public async Task RemoveAsync(string key, CancellationToken cancellationTo
try
{
// 对齐 TS markInternalWrite — 写入前标记内部写
- var targetPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppDataConstants.AppDataFolder, AppDataConstants.SettingsFileName);
+ var targetPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder, AppDataConstants.SettingsFileName);
_configChangeNotifier?.MarkInternalWrite(targetPath);
await ConfigLoader.SaveSettingToSettingsJsonAsync(key, null, _fs, cancellationToken).ConfigureAwait(false);
diff --git a/components/04-guard/Guard/src/DependencyInjection/ServiceRegistration.cs b/components/04-guard/Guard/src/DependencyInjection/ServiceRegistration.cs
index ea8eb472..bbc946dd 100644
--- a/components/04-guard/Guard/src/DependencyInjection/ServiceRegistration.cs
+++ b/components/04-guard/Guard/src/DependencyInjection/ServiceRegistration.cs
@@ -59,7 +59,7 @@ public static IServiceCollection AddHookSystem(this IServiceCollection services)
var manager = new HookConfigurationManager(fs, sp.GetService>());
var logger = sp.GetService>();
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var userSettingsPath = Path.Combine(appDataRoot, AppDataConstants.AppDataFolder, AppDataConstants.SettingsFileName);
manager.RegisterProvider(HookSource.UserSettings,
new JsonFileHookConfigurationProvider(userSettingsPath, HookSource.UserSettings, fs, logger));
diff --git a/components/04-guard/Guard/tests/Unit/Configuration/SettingsLoaderTests.cs b/components/04-guard/Guard/tests/Unit/Configuration/SettingsLoaderTests.cs
index 2aaa5a8d..4d6831e0 100644
--- a/components/04-guard/Guard/tests/Unit/Configuration/SettingsLoaderTests.cs
+++ b/components/04-guard/Guard/tests/Unit/Configuration/SettingsLoaderTests.cs
@@ -26,7 +26,7 @@ public SettingsLoaderTests()
_fs.CreateDirectory(_tempDir);
// 项目设置目录: _tempDir/.jcc/
- _projectAppDataDir = Path.Combine(_tempDir, ".jcc");
+ _projectAppDataDir = Path.Combine(_tempDir, AppDataConstants.AppDataFolder);
_fs.CreateDirectory(_projectAppDataDir);
// 用户设置隔离目录(绝对路径)
diff --git a/components/04-guard/Guard/tests/Unit/Configuration/SettingsMapperTests.cs b/components/04-guard/Guard/tests/Unit/Configuration/SettingsMapperTests.cs
index d7443c92..829b101d 100644
--- a/components/04-guard/Guard/tests/Unit/Configuration/SettingsMapperTests.cs
+++ b/components/04-guard/Guard/tests/Unit/Configuration/SettingsMapperTests.cs
@@ -7,7 +7,7 @@ namespace Guard.Tests.Configuration;
///
public class SettingsMapperTests
{
- private static readonly string DefaultOpenAiModelId = ModelConfigLoader.GetDefaultModelId("openai");
+ private static readonly string OpenAiModelId = ModelConfigLoader.GetDefaultModelId("openai");
private static readonly string DefaultAnthropicModelId = ModelConfigLoader.GetDefaultModelId("anthropic");
private readonly SettingsMapper _mapper = new(new ProviderDefinitionRegistry());
@@ -27,21 +27,6 @@ public class SettingsMapperTests
config.Provider.ModelId.Should().Be(DefaultAnthropicModelId);
}
- [Fact]
- public void Given_null的SettingsJson_When_ToWorkflowConfig_Then_使用默认值()
- {
- // Given
- SettingsJson? settings = null;
-
- // When
- var config = _mapper.ToWorkflowConfig(settings);
-
- // Then
- config.Should().NotBeNull();
- config.Provider.Provider.Should().Be(ProviderKind.OpenAI.ToValue());
- config.Provider.ModelId.Should().Be(DefaultOpenAiModelId);
- }
-
[Fact]
public void Given_FastMode为true的SettingsJson_When_ToWorkflowConfig_Then_FastMode为true()
{
@@ -98,7 +83,7 @@ public class SettingsMapperTests
{
// Given
var config = new WorkflowConfig();
- config.Provider.Provider = ProviderKind.OpenAI.ToValue();
+ config.Provider.Provider = ProviderKind.DeepSeek.ToValue();
Environment.SetEnvironmentVariable(JccEnvVar.Provider.ToValue(), "anthropic");
try
{
@@ -140,7 +125,7 @@ public class SettingsMapperTests
{
// Given
var config = new WorkflowConfig();
- config.Provider.ModelId = DefaultOpenAiModelId;
+ config.Provider.ModelId = OpenAiModelId;
Environment.SetEnvironmentVariable(JccEnvVar.ModelId.ToValue(), "gpt-4o-mini");
try
{
@@ -161,8 +146,9 @@ public class SettingsMapperTests
{
// Given
var config = new WorkflowConfig();
- config.Provider.Provider = ProviderKind.OpenAI.ToValue();
- config.Provider.ModelId = DefaultOpenAiModelId;
+ config.Provider.Provider = ProviderKind.DeepSeek.ToValue();
+ var testModelId = "deepseek-v4-flash";
+ config.Provider.ModelId = testModelId;
// 清除可能存在的环境变量
Environment.SetEnvironmentVariable(JccEnvVar.Provider.ToValue(), null);
Environment.SetEnvironmentVariable(JccEnvVar.ModelId.ToValue(), null);
@@ -173,8 +159,8 @@ public class SettingsMapperTests
_mapper.ApplyEnvOverrides(config);
// Then
- config.Provider.Provider.Should().Be(ProviderKind.OpenAI.ToValue());
- config.Provider.ModelId.Should().Be(DefaultOpenAiModelId);
+ config.Provider.Provider.Should().Be(ProviderKind.DeepSeek.ToValue());
+ config.Provider.ModelId.Should().Be(testModelId);
}
#endregion
@@ -235,7 +221,7 @@ public class SettingsMapperTests
public void Given_两个SettingsJson_When_Merge_Then_高优先级覆盖简单值()
{
// Given
- var baseSettings = new SettingsJson { Model = DefaultOpenAiModelId, Language = "en-US" };
+ var baseSettings = new SettingsJson { Model = OpenAiModelId, Language = "en-US" };
var overrideSettings = new SettingsJson { Model = DefaultAnthropicModelId };
// When
@@ -297,26 +283,26 @@ public class SettingsMapperTests
public void Given_base为null_When_Merge_Then_返回override()
{
// Given
- var overrideSettings = new SettingsJson { Model = DefaultOpenAiModelId };
+ var overrideSettings = new SettingsJson { Model = OpenAiModelId };
// When
var merged = SettingsMapper.Merge(null, overrideSettings);
// Then
- merged.Model.Should().Be(DefaultOpenAiModelId);
+ merged.Model.Should().Be(OpenAiModelId);
}
[Fact]
public void Given_override为null_When_Merge_Then_返回base()
{
// Given
- var baseSettings = new SettingsJson { Model = DefaultOpenAiModelId };
+ var baseSettings = new SettingsJson { Model = OpenAiModelId };
// When
var merged = SettingsMapper.Merge(baseSettings, null);
// Then
- merged.Model.Should().Be(DefaultOpenAiModelId);
+ merged.Model.Should().Be(OpenAiModelId);
}
[Fact]
@@ -338,7 +324,7 @@ public class SettingsMapperTests
public void Given_SettingsJson和环境变量_When_先映射再覆盖_Then_环境变量优先()
{
// Given
- var settings = new SettingsJson { Model = DefaultOpenAiModelId };
+ var settings = new SettingsJson { Model = OpenAiModelId };
Environment.SetEnvironmentVariable(JccEnvVar.ModelId.ToValue(), "gpt-4o-mini");
try
{
diff --git a/components/04-guard/Guard/tests/Unit/Configuration/WorkflowConfigTests.cs b/components/04-guard/Guard/tests/Unit/Configuration/WorkflowConfigTests.cs
index fc216544..3ca80c48 100644
--- a/components/04-guard/Guard/tests/Unit/Configuration/WorkflowConfigTests.cs
+++ b/components/04-guard/Guard/tests/Unit/Configuration/WorkflowConfigTests.cs
@@ -2,22 +2,6 @@
namespace Core.Tests.Configuration;
public class WorkflowConfigTests {
- private static readonly string DefaultOpenAiModelId = ModelConfigLoader.GetDefaultModelId("openai");
-
- [Fact]
- public void WorkflowConfig_DefaultValues_ShouldBeSet() {
- // Act
- var config = new WorkflowConfig {
- Provider = new ProviderConfig {
- ApiKey = TestConfiguration.GetRealApiKey()
- }
- };
-
- // Assert
- Assert.Equal(DefaultOpenAiModelId, config.Provider.ModelId);
- Assert.Equal("workflow_state.json", config.StateFilePath);
- Assert.NotNull(config.Bridge);
- }
[Fact]
public void WorkflowConfig_CodeExecutionConfig_ShouldNotBeNull() {
diff --git a/components/05-memory/Vault/src/Memdir/Memdir/Core/MemoryPaths.cs b/components/05-memory/Vault/src/Memdir/Memdir/Core/MemoryPaths.cs
index b14d98d1..ff7b64c3 100644
--- a/components/05-memory/Vault/src/Memdir/Memdir/Core/MemoryPaths.cs
+++ b/components/05-memory/Vault/src/Memdir/Memdir/Core/MemoryPaths.cs
@@ -1,4 +1,4 @@
-
+
namespace Core.Memdir;
///
@@ -101,7 +101,7 @@ public string GetMemoryFilePath(string memoryId, MemoryType type, string? contex
private static string GetDefaultBaseDirectory()
{
return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
"memories");
}
@@ -165,7 +165,7 @@ public string GetTeamMemberDirectory(string teamId, string userId)
private static string GetDefaultTeamBaseDirectory()
{
return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
"team-memories");
}
diff --git a/components/05-memory/Vault/tests/Unit/State/DatabasePathResolverTests.cs b/components/05-memory/Vault/tests/Unit/State/DatabasePathResolverTests.cs
index dc574d04..6576f695 100644
--- a/components/05-memory/Vault/tests/Unit/State/DatabasePathResolverTests.cs
+++ b/components/05-memory/Vault/tests/Unit/State/DatabasePathResolverTests.cs
@@ -121,7 +121,7 @@ public void Resolve_RelativeAppDataFolder_RootsToApplicationData()
// Assert — 应该在 %APPDATA%/TestJcc 下
var expectedBase = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"TestJcc");
Assert.Equal(Path.Combine(expectedBase, "workflow_state.db"), result);
Assert.DoesNotContain(AppContext.BaseDirectory, result);
diff --git a/components/07-agents/Agents/src/Agents/Agents/Coordinator/Team/TeammateMailboxService.cs b/components/07-agents/Agents/src/Agents/Agents/Coordinator/Team/TeammateMailboxService.cs
index cd6ee4c7..7b7c27ff 100644
--- a/components/07-agents/Agents/src/Agents/Agents/Coordinator/Team/TeammateMailboxService.cs
+++ b/components/07-agents/Agents/src/Agents/Agents/Coordinator/Team/TeammateMailboxService.cs
@@ -1,4 +1,4 @@
-namespace Core.Agents.Coordinator;
+namespace Core.Agents.Coordinator;
[Register]
public sealed partial class TeammateMailboxService : ITeammateMailboxService, IDisposable
@@ -21,7 +21,7 @@ public TeammateMailboxService(
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_mailboxRoot = mailboxRoot
?? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder,
AppDataConstants.MailboxFolderName);
_logger = logger;
diff --git a/components/07-agents/Agents/src/Agents/Configuration/Settings/AgentSettings.cs b/components/07-agents/Agents/src/Agents/Configuration/Settings/AgentSettings.cs
index 1697ba8b..d46441bc 100644
--- a/components/07-agents/Agents/src/Agents/Configuration/Settings/AgentSettings.cs
+++ b/components/07-agents/Agents/src/Agents/Configuration/Settings/AgentSettings.cs
@@ -29,7 +29,7 @@ public class AgentSettings
///
/// 默认模型名称
///
- public string DefaultModelName { get; set; } = ModelConfigLoader.GetDefaultModelId("openai");
+ public string DefaultModelName { get; set; } = ModelConfigLoader.GetDefaultModelId("deepseek");
///
/// 最大上下文长度
diff --git a/components/07-agents/Agents/src/Agents/Services/Support/AgentDefinitionProvider.cs b/components/07-agents/Agents/src/Agents/Services/Support/AgentDefinitionProvider.cs
index 0d85122f..0e1625c2 100644
--- a/components/07-agents/Agents/src/Agents/Services/Support/AgentDefinitionProvider.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Support/AgentDefinitionProvider.cs
@@ -140,7 +140,7 @@ public void ClearCache()
CancellationToken cancellationToken)
{
var definitions = new List();
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var agentsPath = Path.Combine(appDataRoot, AppDataConstants.AppDataFolder, AppDataConstants.AgentsFolderName);
if (_fs.DirectoryExists(agentsPath))
diff --git a/components/07-agents/Agents/src/Agents/Services/Support/AgentMemoryService.cs b/components/07-agents/Agents/src/Agents/Services/Support/AgentMemoryService.cs
index 49aa9cda..2e4f882b 100644
--- a/components/07-agents/Agents/src/Agents/Services/Support/AgentMemoryService.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Support/AgentMemoryService.cs
@@ -1,4 +1,4 @@
-
+
namespace Core.Agents;
///
@@ -35,7 +35,7 @@ public AgentMemoryService(ILogger logger, IFileSystem fs, st
_logger = logger;
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_memoryBase = memoryBase ?? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder);
_cwd = cwd ?? fs.GetCurrentDirectory();
}
diff --git a/components/07-agents/Agents/src/Agents/Services/Support/AgentWorktreeService.cs b/components/07-agents/Agents/src/Agents/Services/Support/AgentWorktreeService.cs
index 48d18b3b..fe8e5feb 100644
--- a/components/07-agents/Agents/src/Agents/Services/Support/AgentWorktreeService.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Support/AgentWorktreeService.cs
@@ -330,6 +330,17 @@ public async Task HasUnpushedCommitsAsync(
}
}
+ public async Task KeepWorktreeAsync(string agentId, CancellationToken cancellationToken = default) {
+ var session = await GetSessionAsync(agentId, cancellationToken).ConfigureAwait(false);
+ if (session is null) return;
+
+ _logger?.LogInformation(
+ "保留 worktree: {WorktreePath}, Agent: {AgentId}, 可通过 cd {WorktreePath} 继续工作",
+ session.WorktreePath, agentId, session.WorktreePath);
+
+ await RemoveSessionAsync(agentId).ConfigureAwait(false);
+ }
+
public async Task> ListWorktreesAsync(
string? gitRootPath = null,
CancellationToken cancellationToken = default) {
@@ -379,6 +390,8 @@ public async Task SaveSessionAsync(AgentWorktreeSession session) {
} finally {
_sessionLock.Release();
}
+
+ await PersistActiveWorktreeSessionAsync(session).ConfigureAwait(false);
}
internal async Task RemoveSessionAsync(string agentId) {
@@ -388,6 +401,77 @@ internal async Task RemoveSessionAsync(string agentId) {
} finally {
_sessionLock.Release();
}
+
+ await ClearActiveWorktreeSessionAsync().ConfigureAwait(false);
+ }
+
+ private async Task PersistActiveWorktreeSessionAsync(AgentWorktreeSession session) {
+ try {
+ var gitRoot = session.GitRootPath;
+ var localSettingsPath = _fileOperationService.CombinePath(gitRoot, WorkflowConstants.Paths.LocalSettingsRelativePath);
+
+ var readResult = await _fileOperationService.ReadFileAsync(localSettingsPath).ConfigureAwait(false);
+ var jsonStr = readResult.Success ? readResult.Content : "{}";
+
+ var root = jsonStr.Length > 0 ? JsonNode.Parse(jsonStr) as JsonObject : new JsonObject();
+
+ root ??= new JsonObject();
+ root["activeWorktreeSession"] = new JsonObject
+ {
+ ["originalCwd"] = session.OriginalCwd,
+ ["worktreePath"] = session.WorktreePath,
+ ["worktreeName"] = session.AgentId,
+ ["worktreeBranch"] = session.BranchName,
+ ["originalBranch"] = session.OriginalBranch,
+ ["originalHeadCommit"] = session.BaseCommitSha,
+ ["sessionId"] = session.AgentId,
+ ["hookBased"] = session.HookBased,
+ ["creationDurationMs"] = session.CreationDurationMs,
+ ["usedSparsePaths"] = session.SparsePaths?.Count > 0
+ };
+
+ var updatedJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+
+ var dir = Path.GetDirectoryName(localSettingsPath);
+ if (!string.IsNullOrEmpty(dir) && !_fileOperationService.DirectoryExists(dir))
+ {
+ _fileOperationService.CreateDirectory(dir);
+ }
+
+ await _fileOperationService.WriteFileAsync(localSettingsPath, updatedJson).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogDebug(ex, "持久化 worktree 会话失败");
+ }
+ }
+
+ private async Task ClearActiveWorktreeSessionAsync() {
+ try {
+ if (_sessions.Count > 0) return;
+
+ var cwd = _fileOperationService.GetCurrentDirectory();
+ var gitRoot = await FindGitRootAsync(cwd).ConfigureAwait(false);
+ if (string.IsNullOrEmpty(gitRoot)) return;
+
+ var localSettingsPath = _fileOperationService.CombinePath(gitRoot, WorkflowConstants.Paths.LocalSettingsRelativePath);
+ if (!_fileOperationService.FileExists(localSettingsPath)) return;
+
+ var readResult = await _fileOperationService.ReadFileAsync(localSettingsPath).ConfigureAwait(false);
+ if (!readResult.Success) return;
+
+ var root = JsonNode.Parse(readResult.Content) as JsonObject;
+ if (root is null) return;
+
+ root.Remove("activeWorktreeSession");
+
+ var updatedJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ await _fileOperationService.WriteFileAsync(localSettingsPath, updatedJson).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogDebug(ex, "清除 worktree 会话持久化失败");
+ }
}
public async ValueTask DisposeAsync() {
@@ -438,6 +522,25 @@ public async Task IsValidWorktreeAsync(string worktreePath, string gitRoot
return result.Success ? result.Output.Trim() : null;
}
+ public async Task GetDefaultBranchAsync(string gitRoot) {
+ var result = await ExecuteGitCommandAsync(gitRoot, "symbolic-ref refs/remotes/origin/HEAD --short").ConfigureAwait(false);
+ if (result.Success && !string.IsNullOrWhiteSpace(result.Output)) {
+ var branch = result.Output.Trim();
+ return branch.StartsWith("origin/") ? branch[7..] : branch;
+ }
+
+ var mainResult = await ExecuteGitCommandAsync(gitRoot, $"{GitSubCommand.RevParse.ToValue()} --verify refs/heads/main").ConfigureAwait(false);
+ if (mainResult.Success) return "main";
+
+ var masterResult = await ExecuteGitCommandAsync(gitRoot, $"{GitSubCommand.RevParse.ToValue()} --verify refs/heads/master").ConfigureAwait(false);
+ return masterResult.Success ? "master" : null;
+ }
+
+ public async Task ResolveRefAsync(string gitRoot, string refName) {
+ var result = await ExecuteGitCommandAsync(gitRoot, $"{GitSubCommand.RevParse.ToValue()} {refName}").ConfigureAwait(false);
+ return result.Success ? result.Output.Trim() : null;
+ }
+
public async Task HasLocalBranchAsync(string gitRoot, string branchName, CancellationToken cancellationToken) {
var result = await ExecuteGitCommandAsync(
gitRoot, $"{GitSubCommand.RevParse.ToValue()} --verify refs/heads/{branchName}", cancellationToken).ConfigureAwait(false);
@@ -460,7 +563,7 @@ public async Task ApplySparseCheckoutAsync(
var paths = string.Join(" ", sparsePaths.Select(p => $"\"{p}\""));
var setResult = await ExecuteGitCommandAsync(
worktreePath,
- $"{GitSubCommand.SparseCheckout.ToValue()} set {paths}",
+ $"sparse-checkout set --cone -- {paths}",
cancellationToken).ConfigureAwait(false);
return setResult.Success;
@@ -614,10 +717,12 @@ private async Task CreateSymlinksAsync(string gitRoot, string worktreePath, IRea
private static bool IsEphemeralWorktree(string dirName, IReadOnlyList patterns) {
return patterns.Any(pattern => {
- var regex = new Regex(
- "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$",
- RegexOptions.IgnoreCase);
- return regex.IsMatch(dirName);
+ try {
+ return Regex.IsMatch(dirName, pattern, RegexOptions.IgnoreCase);
+ }
+ catch {
+ return false;
+ }
});
}
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Context/WorktreeCreateContext.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Context/WorktreeCreateContext.cs
index dbc0f8aa..31b4a921 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Context/WorktreeCreateContext.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Context/WorktreeCreateContext.cs
@@ -26,6 +26,7 @@ public sealed class WorktreeCreateContext : PipelineContextBase
public string? OriginalBranch { get; set; }
public string? BaseCommitSha { get; set; }
+ public string? BaseBranch { get; set; }
// === WorktreeCreateMiddleware 填充 ===
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/IWorktreePipelineOperations.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/IWorktreePipelineOperations.cs
index e2c147ad..40e51033 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/IWorktreePipelineOperations.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/IWorktreePipelineOperations.cs
@@ -9,6 +9,8 @@ public interface IWorktreePipelineOperations
Task SaveSessionAsync(AgentWorktreeSession session);
Task GetCurrentBranchAsync(string gitRoot);
Task GetHeadCommitShaAsync(string gitRoot);
+ Task GetDefaultBranchAsync(string gitRoot);
+ Task ResolveRefAsync(string gitRoot, string refName);
Task IsValidWorktreeAsync(string worktreePath, string gitRoot);
Task ExecuteGitCommandAsync(string workingDirectory, string arguments, CancellationToken cancellationToken = default);
Task HasLocalBranchAsync(string gitRoot, string branchName, CancellationToken cancellationToken);
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeConfigMiddleware.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeConfigMiddleware.cs
index 1b7152eb..bb250a40 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeConfigMiddleware.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeConfigMiddleware.cs
@@ -94,17 +94,59 @@ private async Task CopyWorktreeIncludeFilesAsync(string gitRoot, string worktree
if (!lsResult.Success || string.IsNullOrWhiteSpace(lsResult.Output)) return;
var entries = lsResult.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ var collapsedDirs = new List();
+ var files = new List();
foreach (var entry in entries)
{
var trimmed = entry.Trim();
- if (trimmed.EndsWith('/')) continue;
+ if (trimmed.EndsWith('/'))
+ {
+ collapsedDirs.Add(trimmed);
+ continue;
+ }
+
+ if (patterns.Any(p => MatchesWorktreeIncludePattern(trimmed, p.TrimStart('/'))))
+ {
+ files.Add(trimmed);
+ }
+ }
+
+ if (collapsedDirs.Count > 0)
+ {
+ var dirsToExpand = collapsedDirs.Where(dir =>
+ {
+ var dirNoSlash = dir[..^1];
+ if (patterns.Any(p => PatternTargetsCollapsedDir(p, dir))) return true;
+ if (patterns.Any(p => MatchesWorktreeIncludePattern(dirNoSlash, p.TrimStart('/')))) return true;
+ return false;
+ }).ToList();
- var matches = patterns.Any(p => MatchesWorktreeIncludePattern(trimmed, p.TrimStart('/')));
- if (!matches) continue;
+ if (dirsToExpand.Count > 0)
+ {
+ var expandArgs = "ls-files --others --ignored --exclude-standard -- " +
+ string.Join(" ", dirsToExpand.Select(d => $"\"{d}\""));
+ var expandedResult = await _worktreeService.Value.ExecuteGitCommandAsync(
+ gitRoot, expandArgs, ct).ConfigureAwait(false);
- var srcPath = _fs.CombinePath(gitRoot, trimmed);
- var destPath = _fs.CombinePath(worktreePath, trimmed);
+ if (expandedResult.Success && !string.IsNullOrWhiteSpace(expandedResult.Output))
+ {
+ foreach (var f in expandedResult.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmed = f.Trim();
+ if (patterns.Any(p => MatchesWorktreeIncludePattern(trimmed, p.TrimStart('/'))))
+ {
+ files.Add(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ foreach (var relativePath in files)
+ {
+ var srcPath = _fs.CombinePath(gitRoot, relativePath);
+ var destPath = _fs.CombinePath(worktreePath, relativePath);
if (!_fs.FileExists(srcPath)) continue;
@@ -119,9 +161,14 @@ private async Task CopyWorktreeIncludeFilesAsync(string gitRoot, string worktree
}
catch (Exception ex)
{
- _logger?.LogWarning(ex, "复制 .worktreeinclude 文件失败: {File}", trimmed);
+ _logger?.LogWarning(ex, "复制 .worktreeinclude 文件失败: {File}", relativePath);
}
}
+
+ if (files.Count > 0)
+ {
+ _logger?.LogInformation("从 .worktreeinclude 复制了 {Count} 个文件: {Files}", files.Count, string.Join(", ", files));
+ }
}
catch (Exception ex)
{
@@ -129,29 +176,52 @@ private async Task CopyWorktreeIncludeFilesAsync(string gitRoot, string worktree
}
}
+ private static bool PatternTargetsCollapsedDir(string pattern, string collapsedDir)
+ {
+ var normalized = pattern.StartsWith('/') ? pattern[1..] : pattern;
+ if (normalized.StartsWith(collapsedDir)) return true;
+ var globIdx = normalized.IndexOfAny(['*', '?', '[']);
+ if (globIdx > 0)
+ {
+ var literalPrefix = normalized[..globIdx];
+ if (collapsedDir.StartsWith(literalPrefix, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+ return false;
+ }
+
private async Task ConfigureWorktreeHooksPathAsync(string gitRoot, string worktreePath, CancellationToken ct)
{
try
{
- var hooksResult = await _worktreeService.Value.ExecuteGitCommandAsync(
- gitRoot, "config --get core.hooksPath", ct).ConfigureAwait(false);
+ string? hooksPath = null;
- if (hooksResult.Success && !string.IsNullOrWhiteSpace(hooksResult.Output))
+ var huskyPath = _fs.CombinePath(gitRoot, ".husky");
+ if (_fs.DirectoryExists(huskyPath))
{
- var hooksPath = hooksResult.Output.Trim();
- var absHooksPath = Path.IsPathRooted(hooksPath)
- ? hooksPath
- : _fs.CombinePath(gitRoot, hooksPath);
-
- if (_fs.DirectoryExists(absHooksPath))
+ hooksPath = huskyPath;
+ }
+ else
+ {
+ var gitHooksPath = _fs.CombinePath(gitRoot, ".git", "hooks");
+ if (_fs.DirectoryExists(gitHooksPath))
{
- await _worktreeService.Value.ExecuteGitCommandAsync(
- worktreePath,
- $"config core.hooksPath \"{absHooksPath}\"",
- ct).ConfigureAwait(false);
- _logger?.LogDebug("配置 worktree hooks 路径: {Path}", absHooksPath);
+ hooksPath = gitHooksPath;
}
}
+
+ if (hooksPath is null) return;
+
+ var existingHooksResult = await _worktreeService.Value.ExecuteGitCommandAsync(
+ gitRoot, "config --get core.hooksPath", ct).ConfigureAwait(false);
+
+ var existingHooksPath = existingHooksResult.Success ? existingHooksResult.Output.Trim() : null;
+ if (string.Equals(existingHooksPath, hooksPath, StringComparison.OrdinalIgnoreCase)) return;
+
+ await _worktreeService.Value.ExecuteGitCommandAsync(
+ worktreePath,
+ $"config core.hooksPath \"{hooksPath}\"",
+ ct).ConfigureAwait(false);
+ _logger?.LogDebug("配置 worktree hooks 路径: {Path}", hooksPath);
}
catch (Exception ex)
{
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeCreateMiddleware.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeCreateMiddleware.cs
index 81512f5e..406fb48b 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeCreateMiddleware.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeCreateMiddleware.cs
@@ -23,9 +23,14 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
_logger?.LogInformation("创建新 worktree: {WorktreePath}, Agent: {AgentId}", worktreePath, context.AgentId);
var hasLocalRef = await _worktreeService.Value.HasLocalBranchAsync(gitRoot, branchName, ct).ConfigureAwait(false);
+ var hasSparsePaths = opts.SparsePaths?.Count > 0;
+ var baseRef = context.BaseBranch ?? "HEAD";
+
var worktreeAddArgs = hasLocalRef
? $"worktree add \"{worktreePath}\" -B {branchName}"
- : $"worktree add -B {branchName} \"{worktreePath}\"";
+ : hasSparsePaths
+ ? $"worktree add --no-checkout -B {branchName} \"{worktreePath}\" {baseRef}"
+ : $"worktree add -B {branchName} \"{worktreePath}\" {baseRef}";
var createResult = await _worktreeService.Value.ExecuteGitCommandAsync(gitRoot, worktreeAddArgs, ct).ConfigureAwait(false);
@@ -35,9 +40,9 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
return;
}
- if (opts.SparsePaths?.Count > 0)
+ if (hasSparsePaths)
{
- var sparseResult = await _worktreeService.Value.ApplySparseCheckoutAsync(worktreePath, opts.SparsePaths, ct).ConfigureAwait(false);
+ var sparseResult = await _worktreeService.Value.ApplySparseCheckoutAsync(worktreePath, opts.SparsePaths!, ct).ConfigureAwait(false);
if (!sparseResult)
{
_logger?.LogWarning("应用稀疏检出失败,回滚 worktree: {WorktreePath}", worktreePath);
@@ -46,6 +51,16 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
context.Fail("稀疏检出失败,已回滚 worktree");
return;
}
+
+ var checkoutResult = await _worktreeService.Value.ExecuteGitCommandAsync(worktreePath, "checkout HEAD", ct).ConfigureAwait(false);
+ if (!checkoutResult.Success)
+ {
+ _logger?.LogWarning("稀疏检出 checkout HEAD 失败,回滚 worktree: {WorktreePath}", worktreePath);
+ await _worktreeService.Value.ExecuteGitCommandAsync(gitRoot, $"worktree remove --force \"{worktreePath}\"", ct).ConfigureAwait(false);
+ await _worktreeService.Value.ExecuteGitCommandAsync(gitRoot, $"branch -D {branchName}", ct).ConfigureAwait(false);
+ context.Fail($"稀疏检出 checkout HEAD 失败: {checkoutResult.Error},已回滚 worktree");
+ return;
+ }
}
await next(context, ct).ConfigureAwait(false);
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeGitInfoMiddleware.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeGitInfoMiddleware.cs
index 261aa8f7..5976e5ca 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeGitInfoMiddleware.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeGitInfoMiddleware.cs
@@ -1,12 +1,14 @@
namespace Core.Agents.Worktree;
///
-/// Worktree Git 信息获取中间件 — 获取当前分支和 HEAD commit SHA
+/// Worktree Git 信息获取中间件 — 获取当前分支、HEAD commit SHA,以及基础分支
+/// 对齐 TS getOrCreateWorktree:本地已有 origin ref 时跳过 fetch(大仓库节省 6-8s)
///
[Register(typeof(IWorktreeCreateMiddleware))]
public sealed partial class WorktreeGitInfoMiddleware : IWorktreeCreateMiddleware
{
[Inject] private readonly Lazy _worktreeService;
+ [Inject] private readonly ILogger? _logger;
public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate next, CancellationToken ct)
@@ -16,6 +18,73 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
context.OriginalBranch = await _worktreeService.Value.GetCurrentBranchAsync(gitRoot).ConfigureAwait(false);
context.BaseCommitSha = await _worktreeService.Value.GetHeadCommitShaAsync(gitRoot).ConfigureAwait(false);
+ var opts = context.Options ?? new WorktreeOptions();
+ if (opts.PrNumber.HasValue)
+ {
+ var fetchResult = await _worktreeService.Value.ExecuteGitCommandAsync(
+ gitRoot, $"fetch origin pull/{opts.PrNumber.Value}/head", ct).ConfigureAwait(false);
+
+ if (fetchResult.Success)
+ {
+ context.BaseBranch = "FETCH_HEAD";
+ var fetchHeadSha = await _worktreeService.Value.ResolveRefAsync(gitRoot, "FETCH_HEAD").ConfigureAwait(false);
+ if (fetchHeadSha is not null)
+ {
+ context.BaseCommitSha = fetchHeadSha;
+ }
+ }
+ else
+ {
+ context.Fail($"无法 fetch PR #{opts.PrNumber.Value}: {fetchResult.Error}");
+ return;
+ }
+ }
+ else if (!string.IsNullOrEmpty(opts.BaseBranch))
+ {
+ var baseSha = await _worktreeService.Value.ResolveRefAsync(gitRoot, opts.BaseBranch).ConfigureAwait(false);
+ if (baseSha is not null)
+ {
+ context.BaseCommitSha = baseSha;
+ context.BaseBranch = opts.BaseBranch;
+ }
+ }
+ else
+ {
+ var defaultBranch = await _worktreeService.Value.GetDefaultBranchAsync(gitRoot).ConfigureAwait(false);
+ if (defaultBranch is not null)
+ {
+ var originRef = $"origin/{defaultBranch}";
+ var originSha = await _worktreeService.Value.ResolveRefAsync(gitRoot, originRef).ConfigureAwait(false);
+
+ if (originSha is not null)
+ {
+ context.BaseBranch = originRef;
+ context.BaseCommitSha = originSha;
+ _logger?.LogDebug("本地已有 origin ref,跳过 fetch: {Ref} -> {Sha}", originRef, originSha);
+ }
+ else
+ {
+ var fetchResult = await _worktreeService.Value.ExecuteGitCommandAsync(
+ gitRoot, $"fetch origin {defaultBranch}", ct).ConfigureAwait(false);
+
+ if (fetchResult.Success)
+ {
+ context.BaseBranch = originRef;
+ var fetchedSha = await _worktreeService.Value.ResolveRefAsync(gitRoot, originRef).ConfigureAwait(false);
+ if (fetchedSha is not null)
+ {
+ context.BaseCommitSha = fetchedSha;
+ }
+ }
+ else
+ {
+ context.BaseBranch = "HEAD";
+ _logger?.LogDebug("fetch origin {Branch} 失败,回退到 HEAD: {Error}", defaultBranch, fetchResult.Error);
+ }
+ }
+ }
+ }
+
await next(context, ct).ConfigureAwait(false);
}
}
diff --git a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeRecoveryMiddleware.cs b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeRecoveryMiddleware.cs
index 02b3fe2d..2810784a 100644
--- a/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeRecoveryMiddleware.cs
+++ b/components/07-agents/Agents/src/Agents/Services/Worktree/Pipeline/Middleware/WorktreeRecoveryMiddleware.cs
@@ -2,6 +2,7 @@ namespace Core.Agents.Worktree;
///
/// Worktree 恢复中间件 — 检查现有 worktree 是否可恢复,命中时短路
+/// 对齐 TS readWorktreeHeadSha:直接读取 .git 指针文件获取 HEAD SHA(无子进程,~15ms 优化)
///
[Register(typeof(IWorktreeCreateMiddleware))]
public sealed partial class WorktreeRecoveryMiddleware : IWorktreeCreateMiddleware
@@ -18,10 +19,20 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
var worktreePath = AgentWorktreeSession.GenerateWorktreePath(context.GitRoot, context.AgentId);
var branchName = AgentWorktreeSession.GenerateBranchName(context.AgentId);
- if (_fs.DirectoryExists(worktreePath) && await _worktreeService.Value.IsValidWorktreeAsync(worktreePath, context.GitRoot).ConfigureAwait(false))
+ var existingHeadSha = ReadWorktreeHeadSha(worktreePath);
+ if (existingHeadSha is not null)
{
_logger?.LogInformation("恢复现有 worktree: {WorktreePath}, Agent: {AgentId}", worktreePath, context.AgentId);
+ try
+ {
+ _fs.SetDirectoryLastWriteTimeUtc(worktreePath, _clock.GetUtcNow());
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogDebug(ex, "更新 worktree mtime 失败: {Path}", worktreePath);
+ }
+
var existingSession = new AgentWorktreeSession
{
AgentId = context.AgentId,
@@ -29,6 +40,7 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
WorktreePath = worktreePath,
BranchName = branchName,
GitRootPath = context.GitRoot,
+ BaseCommitSha = existingHeadSha,
CreatedAt = _clock.GetUtcNow(),
Existed = true
};
@@ -39,6 +51,7 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
context.RecoveredSession = existingSession;
context.WorktreePath = worktreePath;
context.BranchName = branchName;
+ context.BaseCommitSha = existingHeadSha;
context.Result = WorktreeCreateResult.SuccessResult(existingSession, true);
return;
}
@@ -48,4 +61,48 @@ public async Task InvokeAsync(WorktreeCreateContext context, MiddlewareDelegate<
await next(context, ct).ConfigureAwait(false);
}
+
+ private string? ReadWorktreeHeadSha(string worktreePath)
+ {
+ try
+ {
+ var gitFile = _fs.CombinePath(worktreePath, ".git");
+ if (!_fs.FileExists(gitFile)) return null;
+
+ var readResult = _fs.ReadFileAsync(gitFile).GetAwaiter().GetResult();
+ if (!readResult.Success) return null;
+
+ var content = readResult.Content.Trim();
+ if (!content.StartsWith("gitdir: ", StringComparison.OrdinalIgnoreCase)) return null;
+
+ var gitdirRelative = content["gitdir: ".Length..].Trim();
+ var gitdirAbs = _fs.CombinePath(worktreePath, gitdirRelative);
+ var normalizedGitdir = Path.GetFullPath(gitdirAbs);
+
+ var headFile = _fs.CombinePath(normalizedGitdir, "HEAD");
+ if (!_fs.FileExists(headFile)) return null;
+
+ var headReadResult = _fs.ReadFileAsync(headFile).GetAwaiter().GetResult();
+ if (!headReadResult.Success) return null;
+
+ var headContent = headReadResult.Content.Trim();
+
+ if (headContent.StartsWith("ref: ", StringComparison.OrdinalIgnoreCase))
+ {
+ var refPath = headContent["ref: ".Length..].Trim();
+ var refFile = _fs.CombinePath(normalizedGitdir, refPath);
+ if (!_fs.FileExists(refFile)) return null;
+
+ var refReadResult = _fs.ReadFileAsync(refFile).GetAwaiter().GetResult();
+ return refReadResult.Success ? refReadResult.Content.Trim() : null;
+ }
+
+ return headContent.Length == 40 && headContent.All(c => char.IsAsciiHexDigit(c)) ? headContent : null;
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogDebug(ex, "读取 worktree HEAD SHA 失败: {Path}", worktreePath);
+ return null;
+ }
+ }
}
diff --git a/components/07-agents/Agents/src/GlobalUsings.cs b/components/07-agents/Agents/src/GlobalUsings.cs
index 2f2a76d4..4bb1d02f 100644
--- a/components/07-agents/Agents/src/GlobalUsings.cs
+++ b/components/07-agents/Agents/src/GlobalUsings.cs
@@ -82,6 +82,7 @@
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
+global using System.Text.Json.Nodes;
global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using System.Threading.Channels;
diff --git a/components/07-agents/Agents/tests/Unit/Agents/Worktree/WorktreeAlignmentTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Worktree/WorktreeAlignmentTests.cs
new file mode 100644
index 00000000..792dd76a
--- /dev/null
+++ b/components/07-agents/Agents/tests/Unit/Agents/Worktree/WorktreeAlignmentTests.cs
@@ -0,0 +1,134 @@
+namespace JoinCode.Agents.Tests.Worktree;
+
+public class WorktreeAlignmentTests
+{
+ [Fact]
+ public void ParsePRReference_ShouldParseHashFormat()
+ {
+ var result = AgentWorktreeSession.ParsePRReference("#123");
+ result.Should().Be(123);
+ }
+
+ [Fact]
+ public void ParsePRReference_ShouldParseGitHubUrl()
+ {
+ var result = AgentWorktreeSession.ParsePRReference("https://github.com/owner/repo/pull/456");
+ result.Should().Be(456);
+ }
+
+ [Fact]
+ public void ParsePRReference_ShouldParseGitHubUrlWithTrailingSlash()
+ {
+ var result = AgentWorktreeSession.ParsePRReference("https://github.com/owner/repo/pull/789/");
+ result.Should().Be(789);
+ }
+
+ [Fact]
+ public void ParsePRReference_ShouldReturnNullForNonPR()
+ {
+ AgentWorktreeSession.ParsePRReference("feature-branch").Should().BeNull();
+ AgentWorktreeSession.ParsePRReference("").Should().BeNull();
+ AgentWorktreeSession.ParsePRReference("123").Should().BeNull();
+ }
+
+ [Fact]
+ public void GenerateBranchName_ShouldFlattenSlashes()
+ {
+ var result = AgentWorktreeSession.GenerateBranchName("user/feature");
+ result.Should().Be("worktree-user+feature");
+ }
+
+ [Fact]
+ public void GenerateBranchName_ShouldNotTruncate()
+ {
+ var longId = new string('a', 64);
+ var result = AgentWorktreeSession.GenerateBranchName(longId);
+ result.Should().Be($"worktree-{longId}");
+ }
+
+ [Fact]
+ public void GenerateBranchName_ShouldFlattenBackslashes()
+ {
+ var result = AgentWorktreeSession.GenerateBranchName("user\\feature");
+ result.Should().Be("worktree-user+feature");
+ }
+
+ [Fact]
+ public void GenerateWorktreePath_ShouldUseJccWorktreesDir()
+ {
+ var result = AgentWorktreeSession.GenerateWorktreePath("/home/user/project", "my-agent");
+ var normalized = result.Replace('\\', '/');
+ normalized.Should().Be("/home/user/project/.jcc/worktrees/my-agent");
+ }
+
+ [Fact]
+ public void GenerateWorktreePath_ShouldFlattenSlashes()
+ {
+ var result = AgentWorktreeSession.GenerateWorktreePath("/home/user/project", "user/feature");
+ var normalized = result.Replace('\\', '/');
+ normalized.Should().Be("/home/user/project/.jcc/worktrees/user+feature");
+ }
+
+ [Theory]
+ [InlineData("agent-a1b2c3d", true)]
+ [InlineData("agent-a1b2c3d4", true)]
+ [InlineData("wf_a1b2c3d4-e1f-5", true)]
+ [InlineData("wf-1", true)]
+ [InlineData("bridge-session1-part2", true)]
+ [InlineData("job-mytemplate-a1b2c3d4", true)]
+ [InlineData("my-feature", false)]
+ [InlineData("work-main", false)]
+ [InlineData("wf-myfeature", false)]
+ public void EphemeralPatterns_ShouldMatchCorrectly(string dirName, bool expectedMatch)
+ {
+ var opts = new WorktreeOptions();
+ var isEphemeral = opts.EphemeralPatterns.Any(pattern =>
+ {
+ try { return System.Text.RegularExpressions.Regex.IsMatch(dirName, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); }
+ catch { return false; }
+ });
+ isEphemeral.Should().Be(expectedMatch);
+ }
+
+ [Fact]
+ public void WorktreeOptions_PrNumber_ShouldDefaultToNull()
+ {
+ var opts = new WorktreeOptions();
+ opts.PrNumber.Should().BeNull();
+ }
+
+ [Fact]
+ public void WorktreeOptions_PrNumber_ShouldBeSettable()
+ {
+ var opts = new WorktreeOptions { PrNumber = 42 };
+ opts.PrNumber.Should().Be(42);
+ }
+
+ [Fact]
+ public async Task KeepWorktreeAsync_ShouldRemoveSessionButKeepDirectory()
+ {
+ var fs = new InMemoryFileOperationService();
+ var processService = new Mock();
+ var service = new AgentWorktreeService(fs, processService.Object);
+
+ var session = new AgentWorktreeSession
+ {
+ AgentId = "test-agent",
+ OriginalCwd = "/home/user/project",
+ WorktreePath = "/home/user/project/.jcc/worktrees/test-agent",
+ BranchName = "worktree-test-agent",
+ GitRootPath = "/home/user/project",
+ CreatedAt = DateTime.UtcNow,
+ Existed = false
+ };
+
+ await service.SaveSessionAsync(session);
+ var savedSession = await service.GetSessionAsync("test-agent");
+ savedSession.Should().NotBeNull();
+
+ await service.KeepWorktreeAsync("test-agent");
+
+ var afterKeep = await service.GetSessionAsync("test-agent");
+ afterKeep.Should().BeNull();
+ }
+}
diff --git a/components/08-transport/Bridge/src/Core/BridgePointer.cs b/components/08-transport/Bridge/src/Core/BridgePointer.cs
index f6c891f4..fb9b775c 100644
--- a/components/08-transport/Bridge/src/Core/BridgePointer.cs
+++ b/components/08-transport/Bridge/src/Core/BridgePointer.cs
@@ -1,4 +1,4 @@
-
+
namespace Core.Bridge;
#region BridgePointerSource 枚举
@@ -72,7 +72,7 @@ public static string GetPointerPath(string dir)
{
ArgumentNullException.ThrowIfNull(dir);
var appData = Environment.GetEnvironmentVariable("JCC_APP_DATA_FOLDER")
- ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jcc");
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder);
var safeDir = dir.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_');
var projectsDir = Path.Combine(appData, "projects", safeDir);
return Path.Combine(projectsDir, "bridge-pointer.json");
diff --git a/components/08-transport/Bridge/src/Core/Runtime/ConcurrentSession.cs b/components/08-transport/Bridge/src/Core/Runtime/ConcurrentSession.cs
index 3d6da3c2..091218d7 100644
--- a/components/08-transport/Bridge/src/Core/Runtime/ConcurrentSession.cs
+++ b/components/08-transport/Bridge/src/Core/Runtime/ConcurrentSession.cs
@@ -1,4 +1,4 @@
-
+
namespace Core.Bridge;
#region SessionKind 枚举
@@ -112,7 +112,7 @@ public ConcurrentSessionService(IFileSystem fs, ILogger? logger = null, IClockSe
public static string GetSessionsDir()
{
var appData = Environment.GetEnvironmentVariable("JCC_APP_DATA_FOLDER")
- ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jcc");
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), AppDataConstants.AppDataFolder);
return Path.Combine(appData, "sessions");
}
diff --git a/components/08-transport/Bridge/src/Messaging/BridgeInboundAttachments.cs b/components/08-transport/Bridge/src/Messaging/BridgeInboundAttachments.cs
index 9f1fd4a4..a0197d7c 100644
--- a/components/08-transport/Bridge/src/Messaging/BridgeInboundAttachments.cs
+++ b/components/08-transport/Bridge/src/Messaging/BridgeInboundAttachments.cs
@@ -69,8 +69,8 @@ public static async Task ResolveInboundAttachmentsAsync(
if (attachments.Count == 0) return string.Empty;
var uploadDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "jcc", "uploads", sessionId);
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ AppDataConstants.AppDataFolder, "uploads", sessionId);
fs.CreateDirectory(uploadDir);
diff --git a/components/08-transport/Bridge/src/Security/BridgeDeviceTokenService.cs b/components/08-transport/Bridge/src/Security/BridgeDeviceTokenService.cs
index c729f13a..85983fee 100644
--- a/components/08-transport/Bridge/src/Security/BridgeDeviceTokenService.cs
+++ b/components/08-transport/Bridge/src/Security/BridgeDeviceTokenService.cs
@@ -33,8 +33,8 @@ internal BridgeDeviceTokenService(HttpClient httpClient, IFileSystem fs, ILogger
_logger = logger;
_authFilePath = authFilePath
?? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "jcc", "auth.json");
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ AppDataConstants.AppDataFolder, "auth.json");
}
///
diff --git a/components/08-transport/Bridge/src/Session/BridgeSubprocessManager.cs b/components/08-transport/Bridge/src/Session/BridgeSubprocessManager.cs
index 238f2b13..0df3fa3a 100644
--- a/components/08-transport/Bridge/src/Session/BridgeSubprocessManager.cs
+++ b/components/08-transport/Bridge/src/Session/BridgeSubprocessManager.cs
@@ -550,7 +550,7 @@ public async Task SpawnAsync(BridgeSubprocessOptions opt
else if (options.Verbose || IsAntBuild())
{
var tempDir = Path.GetTempPath();
- debugFile = Path.Combine(tempDir, "jcc", $"bridge-session-{safeId}.log");
+ debugFile = Path.Combine(tempDir, AppDataConstants.AppDataFolder, $"bridge-session-{safeId}.log");
}
// 构建带 transcript 的路径
diff --git a/sdk/Abstractions/00-core/Configuration/AppData/AppDataPaths.cs b/sdk/Abstractions/00-core/Configuration/AppData/AppDataPaths.cs
index c3d5bf87..239174fb 100644
--- a/sdk/Abstractions/00-core/Configuration/AppData/AppDataPaths.cs
+++ b/sdk/Abstractions/00-core/Configuration/AppData/AppDataPaths.cs
@@ -39,7 +39,7 @@ public sealed record AppDataPaths(
public static AppDataPaths FromEnvironment()
{
return new AppDataPaths(
- AppDataFolder: ResolveEnv(JccEnvVar.AppDataFolder, "jcc"),
+ AppDataFolder: ResolveEnv(JccEnvVar.AppDataFolder, ".jcc"),
CredentialsFileName: ResolveEnv(JccEnvVar.CredentialsFileName, "credentials.json"),
AuthFileName: ResolveEnv(JccEnvVar.AuthFileName, "auth.json"),
SettingsFileName: ResolveEnv(JccEnvVar.SettingsFileName, "settings.json"),
@@ -81,7 +81,7 @@ public static AppDataPaths CreateForTest(
}
///
- /// 获取 .jcc 目录的完整路径
+ /// 获取 .jcc 目录的完整路径 — 统一使用 UserProfile(~/.jcc/)
///
public string JccDirectory
{
@@ -91,7 +91,7 @@ public string JccDirectory
return AppDataFolder;
return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataFolder);
}
}
diff --git a/sdk/Abstractions/00-core/Configuration/AppData/WorkflowConstants.cs b/sdk/Abstractions/00-core/Configuration/AppData/WorkflowConstants.cs
index 47e13e6d..624cf1df 100644
--- a/sdk/Abstractions/00-core/Configuration/AppData/WorkflowConstants.cs
+++ b/sdk/Abstractions/00-core/Configuration/AppData/WorkflowConstants.cs
@@ -152,7 +152,7 @@ public static class Paths
public const int DefaultBridgePort = 3456;
///
- /// 获取 .jcc 目录的完整路径
+ /// 获取 .jcc 目录的完整路径 — 统一使用 UserProfile(~/.jcc/)
/// 优先使用 JCC_APP_DATA_FOLDER 环境变量覆盖(测试隔离场景)
/// 其次检查 AppDataConstants.AppDataFolder 是否为绝对路径(backing field 覆盖场景)
///
@@ -169,7 +169,7 @@ public static string JccDirectory
return appDataFolder;
return Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
appDataFolder);
}
}
diff --git a/sdk/Abstractions/00-core/Configuration/Llm/ModelConfigLoader.cs b/sdk/Abstractions/00-core/Configuration/Llm/ModelConfigLoader.cs
index a6fa67c9..eb221744 100644
--- a/sdk/Abstractions/00-core/Configuration/Llm/ModelConfigLoader.cs
+++ b/sdk/Abstractions/00-core/Configuration/Llm/ModelConfigLoader.cs
@@ -189,7 +189,7 @@ private static void ApplyUserOverride(ModelConfigRoot config)
{
var userConfigPath = System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
- ".jcc", "models.json");
+ AppData.AppDataConstants.AppDataFolder, "models.json");
if (!System.IO.File.Exists(userConfigPath))
return;
diff --git a/sdk/Abstractions/00-core/Configuration/Providers/ProviderConfig.cs b/sdk/Abstractions/00-core/Configuration/Providers/ProviderConfig.cs
index 4dc9718a..2aa5ffef 100644
--- a/sdk/Abstractions/00-core/Configuration/Providers/ProviderConfig.cs
+++ b/sdk/Abstractions/00-core/Configuration/Providers/ProviderConfig.cs
@@ -7,10 +7,10 @@ namespace JoinCode.Abstractions.Configuration.Providers;
public class ProviderConfig
{
///
- /// Provider 类型: openai, azure, anthropic, agnes
+ /// Provider 类型: deepseek, openai, azure, anthropic, agnes
///
[Required]
- public string Provider { get; set; } = ProviderKind.OpenAI.ToValue();
+ public string Provider { get; set; } = ProviderKind.DeepSeek.ToValue();
///
/// API Key
@@ -20,7 +20,7 @@ public class ProviderConfig
///
/// 模型 ID
///
- public string ModelId { get; set; } = ModelConfigLoader.GetDefaultModelId("openai");
+ public string ModelId { get; set; } = ModelConfigLoader.GetDefaultModelId("deepseek");
///
/// API 端点(Azure 需要)
@@ -45,7 +45,7 @@ public class ProviderConfig
///
/// 从 Provider 字符串推导的 ProviderKind 枚举 — 替代字符串比较,编译时类型安全
///
- public ProviderKind Kind => ProviderKindExtensions.FromValue(Provider) ?? ProviderKind.OpenAI;
+ public ProviderKind Kind => ProviderKindExtensions.FromValue(Provider) ?? ProviderKind.DeepSeek;
///
/// Provider 完整定义 — 由 ConfigLoader 在加载时注入,QueryService 等消费者通过此属性访问 Provider 知识
diff --git a/sdk/Abstractions/00-core/Models/Agent/AgentWorktreeSession.cs b/sdk/Abstractions/00-core/Models/Agent/AgentWorktreeSession.cs
index c11aac9a..f6d816c2 100644
--- a/sdk/Abstractions/00-core/Models/Agent/AgentWorktreeSession.cs
+++ b/sdk/Abstractions/00-core/Models/Agent/AgentWorktreeSession.cs
@@ -70,8 +70,7 @@ public sealed record AgentWorktreeSession {
///
public static string GenerateBranchName(string agentId) {
var safeId = agentId.Replace("/", "+").Replace("\\", "+");
- var shortId = safeId.Length > 8 ? safeId[..8] : safeId;
- return $"worktree-agent-{shortId}";
+ return $"worktree-{safeId}";
}
///
@@ -81,6 +80,26 @@ public static string GenerateWorktreePath(string gitRoot, string agentId) {
var safeId = agentId.Replace("/", "+").Replace("\\", "+");
return WorkflowConstants.Paths.GetProjectWorktreePath(gitRoot, agentId);
}
+
+ ///
+ /// 解析 PR 引用 — 对齐 TS parsePRReference
+ /// 支持 #N 格式和 GitHub PR URL(如 https://github.com/owner/repo/pull/123)
+ ///
+ public static int? ParsePRReference(string input) {
+ var urlMatch = Regex.Match(input, @"^https?://[^/]+/[^/]+/[^/]+/pull/(\d+)/?(?:[?#].*)?$", RegexOptions.IgnoreCase);
+ if (urlMatch.Success && int.TryParse(urlMatch.Groups[1].Value, out var urlPrNum))
+ {
+ return urlPrNum;
+ }
+
+ var hashMatch = Regex.Match(input, @"^#(\d+)$");
+ if (hashMatch.Success && int.TryParse(hashMatch.Groups[1].Value, out var hashPrNum))
+ {
+ return hashPrNum;
+ }
+
+ return null;
+ }
}
///
@@ -92,6 +111,11 @@ public sealed record WorktreeOptions {
///
public string? BaseBranch { get; init; }
+ ///
+ /// PR 编号(可选)— 对齐 TS parsePRReference,支持 #N 和 GitHub PR URL
+ ///
+ public int? PrNumber { get; init; }
+
///
/// 稀疏检出路径列表(可选)
///
@@ -135,12 +159,15 @@ public int StaleTimeoutHours {
///
/// 临时 worktree 名称模式(用于自动清理)
+ /// 对齐 TS EPHEMERAL_WORKTREE_PATTERNS:精确正则匹配,避免误删用户命名的 worktree
///
public IReadOnlyList EphemeralPatterns { get; init; } =
[
- "agent-*",
- "wf-*",
- "bridge-*"
+ "^agent-[a-z0-9]{1,8}$",
+ "^wf_[0-9a-f]{8}-[0-9a-f]{3}-\\d+$",
+ "^wf-\\d+$",
+ "^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$",
+ "^job-[a-zA-Z0-9._-]{1,55}-[0-9a-f]{8}$"
];
}
diff --git a/sdk/Abstractions/05-memory/FileIO/IFileOperationService.cs b/sdk/Abstractions/05-memory/FileIO/IFileOperationService.cs
index b72153c4..60197c4b 100644
--- a/sdk/Abstractions/05-memory/FileIO/IFileOperationService.cs
+++ b/sdk/Abstractions/05-memory/FileIO/IFileOperationService.cs
@@ -99,6 +99,11 @@ Task ListDirectoryAsync(
///
DateTime GetDirectoryLastWriteTimeUtc(string directoryPath);
+ ///
+ /// 设置目录最后写入时间(UTC)— 对齐 TS utimes,防止过期清理误删恢复的 worktree
+ ///
+ void SetDirectoryLastWriteTimeUtc(string directoryPath, DateTime utcTime);
+
///
/// 获取文件最后写入时间
///
diff --git a/sdk/Abstractions/05-memory/FileIO/IFileSystem.cs b/sdk/Abstractions/05-memory/FileIO/IFileSystem.cs
index d886bdf4..a9703210 100644
--- a/sdk/Abstractions/05-memory/FileIO/IFileSystem.cs
+++ b/sdk/Abstractions/05-memory/FileIO/IFileSystem.cs
@@ -133,6 +133,9 @@ public interface IFileSystem
/// 获取目录最后写入时间(UTC) — 对齐 Directory.GetLastWriteTimeUtc
DateTime GetDirectoryLastWriteTimeUtc(string path);
+ /// 设置目录最后写入时间(UTC) — 对齐 Directory.SetLastWriteTimeUtc
+ void SetDirectoryLastWriteTimeUtc(string path, DateTime utcTime);
+
/// 获取父目录路径 — 对齐 DirectoryInfo.Parent.FullName,根目录返回 null
string? GetParentPath(string path);
diff --git a/sdk/Abstractions/07-agents/Agent/Worktree/IAgentWorktreeService.cs b/sdk/Abstractions/07-agents/Agent/Worktree/IAgentWorktreeService.cs
index 3575a5f2..5a36bffe 100644
--- a/sdk/Abstractions/07-agents/Agent/Worktree/IAgentWorktreeService.cs
+++ b/sdk/Abstractions/07-agents/Agent/Worktree/IAgentWorktreeService.cs
@@ -105,4 +105,12 @@ Task HasUnpushedCommitsAsync(
Task> ListWorktreesAsync(
string? gitRootPath = null,
CancellationToken cancellationToken = default);
+
+ ///
+ /// 保留 Worktree 但清除会话 — 对齐 TS keepWorktree
+ /// Worktree 目录和分支保留,但会话从内存和持久化中移除
+ ///
+ /// 智能体 ID
+ /// 取消令牌
+ Task KeepWorktreeAsync(string agentId, CancellationToken cancellationToken = default);
}
diff --git a/src/Infrastructure/IO/FileSystem/InMemoryFileSystem.cs b/src/Infrastructure/IO/FileSystem/InMemoryFileSystem.cs
index e0b25974..b4f06412 100644
--- a/src/Infrastructure/IO/FileSystem/InMemoryFileSystem.cs
+++ b/src/Infrastructure/IO/FileSystem/InMemoryFileSystem.cs
@@ -485,6 +485,9 @@ public void MoveDirectory(string sourceDir, string destDir)
public DateTime GetDirectoryLastWriteTimeUtc(string path)
=> DateTime.UtcNow;
+ ///
+ public void SetDirectoryLastWriteTimeUtc(string path, DateTime utcTime) { }
+
///
public string? GetParentPath(string path)
{
diff --git a/src/Infrastructure/IO/FileSystem/PhysicalFileSystem.cs b/src/Infrastructure/IO/FileSystem/PhysicalFileSystem.cs
index 0a1163ee..f1a5a786 100644
--- a/src/Infrastructure/IO/FileSystem/PhysicalFileSystem.cs
+++ b/src/Infrastructure/IO/FileSystem/PhysicalFileSystem.cs
@@ -186,6 +186,10 @@ public void MoveDirectory(string sourceDir, string destDir)
public DateTime GetDirectoryLastWriteTimeUtc(string path)
=> Directory.GetLastWriteTimeUtc(path);
+ ///
+ public void SetDirectoryLastWriteTimeUtc(string path, DateTime utcTime)
+ => Directory.SetLastWriteTimeUtc(path, utcTime);
+
///
public string? GetParentPath(string path)
{
diff --git a/src/Infrastructure/IO/Services/FileOps/FileHistoryService.cs b/src/Infrastructure/IO/Services/FileOps/FileHistoryService.cs
index e54aa5f7..2c56b376 100644
--- a/src/Infrastructure/IO/Services/FileOps/FileHistoryService.cs
+++ b/src/Infrastructure/IO/Services/FileOps/FileHistoryService.cs
@@ -22,7 +22,7 @@ public FileHistoryService(IFileSystem fs, ILogger? logger =
_logger = logger;
_sessionId = Environment.ProcessId.ToString();
- var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
_baseDir = Path.Combine(homeDir, AppDataConstants.AppDataFolder, AppDataConstants.FileHistoryFolderName);
}
diff --git a/src/Infrastructure/IO/Services/FileOps/FileOperationService.cs b/src/Infrastructure/IO/Services/FileOps/FileOperationService.cs
index 9ab21c16..5d220a91 100644
--- a/src/Infrastructure/IO/Services/FileOps/FileOperationService.cs
+++ b/src/Infrastructure/IO/Services/FileOps/FileOperationService.cs
@@ -168,6 +168,13 @@ public DateTime GetDirectoryLastWriteTimeUtc(string directoryPath)
return _fs.GetDirectoryLastWriteTimeUtc(normalizedPath);
}
+ ///
+ public void SetDirectoryLastWriteTimeUtc(string directoryPath, DateTime utcTime)
+ {
+ var normalizedPath = NormalizePath(directoryPath);
+ _fs.SetDirectoryLastWriteTimeUtc(normalizedPath, utcTime);
+ }
+
///
public DateTime GetFileLastWriteTime(string filePath)
{
diff --git a/src/Infrastructure/IO/Services/FileOps/McpOutputStorage.cs b/src/Infrastructure/IO/Services/FileOps/McpOutputStorage.cs
index af7c4cdb..1f06e0ad 100644
--- a/src/Infrastructure/IO/Services/FileOps/McpOutputStorage.cs
+++ b/src/Infrastructure/IO/Services/FileOps/McpOutputStorage.cs
@@ -18,8 +18,8 @@ public McpOutputStorage(IFileSystem fs, ILogger? logger = null
_fs = fs;
_logger = logger;
_baseDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "jcc",
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ AppDataConstants.AppDataFolder,
"mcp-output");
}
diff --git a/src/Infrastructure/IO/Services/FileOps/ThrottledFileService.cs b/src/Infrastructure/IO/Services/FileOps/ThrottledFileService.cs
index 5dfbe923..e9c91be6 100644
--- a/src/Infrastructure/IO/Services/FileOps/ThrottledFileService.cs
+++ b/src/Infrastructure/IO/Services/FileOps/ThrottledFileService.cs
@@ -485,6 +485,12 @@ public DateTime GetDirectoryLastWriteTimeUtc(string directoryPath)
return _fs.GetDirectoryLastWriteTimeUtc(NormalizePath(directoryPath));
}
+ ///
+ public void SetDirectoryLastWriteTimeUtc(string directoryPath, DateTime utcTime)
+ {
+ _fs.SetDirectoryLastWriteTimeUtc(NormalizePath(directoryPath), utcTime);
+ }
+
///
public DateTime GetFileLastWriteTime(string filePath)
{
diff --git a/src/Infrastructure/Ssh/SshForwardedPort.cs b/src/Infrastructure/Ssh/SshForwardedPort.cs
index d00fbd2c..5c57f0e8 100644
--- a/src/Infrastructure/Ssh/SshForwardedPort.cs
+++ b/src/Infrastructure/Ssh/SshForwardedPort.cs
@@ -57,8 +57,8 @@ public Task StartAsync(CancellationToken ct = default)
if (_config.AuthMethod == SshAuthMethod.PrivateKey && _config.PrivateKey != null)
{
var keyFile = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "jcc", "ssh", $"key_{_sessionId}");
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ AppDataConstants.AppDataFolder, "ssh", $"key_{_sessionId}");
args.Append($" -i \"{keyFile}\"");
}
diff --git a/src/Infrastructure/Ssh/SshSession.cs b/src/Infrastructure/Ssh/SshSession.cs
index 56d7ba3e..14bbc966 100644
--- a/src/Infrastructure/Ssh/SshSession.cs
+++ b/src/Infrastructure/Ssh/SshSession.cs
@@ -389,8 +389,8 @@ private void AddAuthArgs(ProcessStartInfo startInfo)
{
case SshAuthMethod.PrivateKey when Config.PrivateKey != null:
var keyFile = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "jcc", "ssh", $"key_{SessionId}");
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ AppDataConstants.AppDataFolder, "ssh", $"key_{SessionId}");
_fs.CreateDirectory(Path.GetDirectoryName(keyFile)!);
_fs.WriteAllText(keyFile, Config.PrivateKey);
diff --git a/src/JoinCode/Cli/Onboarding/OnboardingStatePersistence.cs b/src/JoinCode/Cli/Onboarding/OnboardingStatePersistence.cs
index 744d604c..92f8f730 100644
--- a/src/JoinCode/Cli/Onboarding/OnboardingStatePersistence.cs
+++ b/src/JoinCode/Cli/Onboarding/OnboardingStatePersistence.cs
@@ -15,7 +15,7 @@ public OnboardingStatePersistence(IFileSystem fs, IClockService? clock = null)
_fs = fs;
_clock = clock ?? SystemClockService.Instance;
var appDataPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder);
_filePath = Path.Combine(appDataPath, "onboarding_complete.json");
}
diff --git a/src/JoinCode/Cli/TrustFolderManager.cs b/src/JoinCode/Cli/TrustFolderManager.cs
index 6db11486..fa4d95ad 100644
--- a/src/JoinCode/Cli/TrustFolderManager.cs
+++ b/src/JoinCode/Cli/TrustFolderManager.cs
@@ -1,4 +1,4 @@
-namespace JoinCode.Cli;
+namespace JoinCode.Cli;
///
/// 信任目录管理器 — CLI 简化版,实现 ITrustFolderManager
@@ -13,7 +13,7 @@ public TrustFolderManager(IFileSystem fs)
{
_fs = fs;
var appDataPath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder);
_trustedFoldersPath = Path.Combine(appDataPath, AppDataConstants.TrustedFoldersFileName);
}
diff --git a/src/JoinCode/Commands/guard/Config/InitCommand.cs b/src/JoinCode/Commands/guard/Config/InitCommand.cs
index fb7bbcc3..bc245efe 100644
--- a/src/JoinCode/Commands/guard/Config/InitCommand.cs
+++ b/src/JoinCode/Commands/guard/Config/InitCommand.cs
@@ -78,8 +78,16 @@ private static async Task QuickInitAsync(ChatCommandContext context)
if (!fs.FileExists(settingsFile))
{
- var defaultModel = JoinCode.Abstractions.Configuration.Llm.ModelConfigLoader.GetDefaultModelId("openai");
- await fs.WriteAllTextAsync(settingsFile, $"{{\n \"provider\": \"openai\",\n \"model\": \"{defaultModel}\"\n}}\n", context.CancellationToken).ConfigureAwait(false);
+ var defaultModel = JoinCode.Abstractions.Configuration.Llm.ModelConfigLoader.GetDefaultModelId("deepseek");
+ var sb = new StringBuilder();
+ sb.AppendLine("{");
+ sb.AppendLine(" // 项目级 Provider(覆盖全局配置)");
+ sb.AppendLine(" \"provider\": \"deepseek\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 项目级模型 ID");
+ sb.AppendLine($" \"model\": \"{defaultModel}\"");
+ sb.Append('}');
+ await fs.WriteAllTextAsync(settingsFile, sb.ToString(), context.CancellationToken).ConfigureAwait(false);
TerminalHelper.WriteLine(" ✓ 创建 settings.json");
}
else
diff --git a/src/JoinCode/Commands/guard/Config/ResetConfigCommand.cs b/src/JoinCode/Commands/guard/Config/ResetConfigCommand.cs
index ee0a2cb6..4fd7b111 100644
--- a/src/JoinCode/Commands/guard/Config/ResetConfigCommand.cs
+++ b/src/JoinCode/Commands/guard/Config/ResetConfigCommand.cs
@@ -1,4 +1,4 @@
-
+
namespace JoinCode.ChatCommands;
[ChatCommand(Name = ChatCommandNameConstants.ResetConfig, Description = "重置配置文件到默认状态", Usage = "/reset-config [all|auth|settings|trust|onboarding]", Category = ChatCommandCategory.Config, ArgumentHint = "[all|auth|settings|trust|onboarding]")]
@@ -8,7 +8,7 @@ public async override Task ExecuteAsync(ChatCommandContext co
{
var args = ChatCommandBase.GetNormalizedArgs(context).ToLowerInvariant();
var fs = context.Services.FileSystem;
- var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var jccDir = Path.Combine(appDataRoot, AppDataConstants.AppDataFolder);
if (args == "all" || string.IsNullOrEmpty(args))
diff --git a/src/JoinCode/Commands/system/Social/ShareCommand.cs b/src/JoinCode/Commands/system/Social/ShareCommand.cs
index 50b836a4..4ed7140d 100644
--- a/src/JoinCode/Commands/system/Social/ShareCommand.cs
+++ b/src/JoinCode/Commands/system/Social/ShareCommand.cs
@@ -1,4 +1,4 @@
-namespace JoinCode.ChatCommands;
+namespace JoinCode.ChatCommands;
///
/// /share 命令 — 对齐 TS share/
@@ -53,7 +53,7 @@ public async override Task ExecuteAsync(ChatCommandContext co
try
{
var sharePath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
AppDataConstants.AppDataFolder, "shares",
$"share-{DateTime.Now:yyyyMMdd-HHmmss}.md");
diff --git a/src/JoinCode/Commands/system/System/CopyCommand.cs b/src/JoinCode/Commands/system/System/CopyCommand.cs
index 00519095..0b5902d4 100644
--- a/src/JoinCode/Commands/system/System/CopyCommand.cs
+++ b/src/JoinCode/Commands/system/System/CopyCommand.cs
@@ -82,7 +82,7 @@ private static async Task CopyWithFallbackAsync(
// 对齐 TS: 同时写入临时文件作为回退(OSC52需要终端支持)
try
{
- var tempDir = Path.Combine(Path.GetTempPath(), "jcc");
+ var tempDir = Path.Combine(Path.GetTempPath(), AppDataConstants.AppDataFolder);
DirectoryHelper.EnsureDirectoryExists(fs, tempDir);
var filePath = Path.Combine(tempDir, filename);
await fs.WriteAllTextAsync(filePath, text, ct).ConfigureAwait(false);
diff --git a/src/JoinCode/Entry/StartupWorkflow.cs b/src/JoinCode/Entry/StartupWorkflow.cs
index 61010ce2..9b1a67a7 100644
--- a/src/JoinCode/Entry/StartupWorkflow.cs
+++ b/src/JoinCode/Entry/StartupWorkflow.cs
@@ -2,6 +2,32 @@ namespace JoinCode.Entry;
internal static class StartupWorkflow
{
+ ///
+ /// 确保全局配置文件存在 — 首次启动时自动创建带注释的模板,不覆盖已有文件
+ /// 必须在 LoadConfigAsync 之前调用,确保配置文件可供加载
+ ///
+ internal static async Task EnsureConfigFilesExistAsync(IFileSystem fs)
+ {
+ var appDataPath = WorkflowConstants.Paths.JccDirectory;
+ var settingsPath = Path.Combine(appDataPath, AppDataConstants.SettingsFileName);
+ var authPath = Path.Combine(appDataPath, AppDataConstants.AuthFileName);
+
+ if (!fs.DirectoryExists(appDataPath))
+ {
+ DirectoryHelper.EnsureDirectoryExists(fs, appDataPath);
+ }
+
+ if (!fs.FileExists(settingsPath))
+ {
+ await fs.WriteAllTextAsync(settingsPath, BuildDefaultSettingsTemplate()).ConfigureAwait(false);
+ }
+
+ if (!fs.FileExists(authPath))
+ {
+ await fs.WriteAllTextAsync(authPath, BuildDefaultAuthTemplate()).ConfigureAwait(false);
+ }
+ }
+
internal static async Task RunOnboardingIfNeededAsync(IOnboardingService onboardingService, CommandLineOptions options, IFileSystem fs, bool hasApiKey, IProviderDefinitionRegistry registry, WorkflowConfig? config = null)
{
await onboardingService.InitializeAsync();
@@ -84,19 +110,6 @@ internal static async Task RunOnboardingIfNeededAsync(IOnboardingService onboard
}
}
}
-
- var appDataPath = WorkflowConstants.Paths.JccDirectory;
- var settingsPath = Path.Combine(appDataPath, AppDataConstants.SettingsFileName);
-
- if (!fs.DirectoryExists(appDataPath))
- {
- DirectoryHelper.EnsureDirectoryExists(fs, appDataPath);
- }
-
- if (!fs.FileExists(settingsPath))
- {
- await fs.WriteAllTextAsync(settingsPath, "{}").ConfigureAwait(false);
- }
}
///
@@ -244,4 +257,54 @@ internal static async Task CheckWorkspaceTrustAsync(CommandLineOptions opt
return false;
}
+
+ private static string BuildDefaultSettingsTemplate()
+ {
+ var defaultModel = JoinCode.Abstractions.Configuration.Llm.ModelConfigLoader.GetDefaultModelId("deepseek");
+ var sb = new StringBuilder();
+ sb.AppendLine("{");
+ sb.AppendLine(" // LLM Provider: deepseek | openai | anthropic | azure | agnes");
+ sb.AppendLine(" \"provider\": \"deepseek\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 模型 ID,可用 /model 命令切换");
+ sb.AppendLine($" \"model\": \"{defaultModel}\",");
+ sb.AppendLine();
+ sb.AppendLine(" // API 端点(默认使用 Provider 内置地址,无需修改)");
+ sb.AppendLine(" // \"endpoint\": \"https://api.deepseek.com\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 推理努力级别: low | medium | high");
+ sb.AppendLine(" // \"effortLevel\": \"high\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 快速模式:使用轻量模型处理简单任务");
+ sb.AppendLine(" // \"fastMode\": false,");
+ sb.AppendLine();
+ sb.AppendLine(" // 默认 Shell: powershell | bash | cmd");
+ sb.AppendLine(" // \"defaultShell\": \"powershell\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 语言: zh-CN | en-US");
+ sb.AppendLine(" // \"language\": \"zh-CN\",");
+ sb.AppendLine();
+ sb.AppendLine(" // 自动记忆:自动将重要信息保存到记忆文件");
+ sb.AppendLine(" // \"autoMemoryEnabled\": true");
+ sb.Append('}');
+ return sb.ToString();
+ }
+
+ private static string BuildDefaultAuthTemplate()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("{");
+ sb.AppendLine(" // API Key 存储 — 键名为 Provider 名称,值为对应的 API Key");
+ sb.AppendLine(" // 支持的键名: deepseek | openai | anthropic | azure | agnes");
+ sb.AppendLine(" //");
+ sb.AppendLine(" // 示例:");
+ sb.AppendLine(" // \"deepseek\": \"sk-your-deepseek-api-key\",");
+ sb.AppendLine(" // \"openai\": \"sk-your-openai-api-key\",");
+ sb.AppendLine(" // \"anthropic\": \"sk-ant-your-anthropic-api-key\"");
+ sb.AppendLine(" //");
+ sb.AppendLine(" // 也可通过环境变量设置(优先级更高):");
+ sb.AppendLine(" // DEEPSEEK_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / AZURE_OPENAI_API_KEY");
+ sb.Append('}');
+ return sb.ToString();
+ }
}
diff --git a/src/JoinCode/Program.cs b/src/JoinCode/Program.cs
index f974b4cd..0f883152 100644
--- a/src/JoinCode/Program.cs
+++ b/src/JoinCode/Program.cs
@@ -57,6 +57,10 @@ static async Task Main(string[] args)
using var awaitTimer = StartAwaitTimer(options);
var fs = IO.FileSystem.FileSystemFactory.Create();
+
+ // 首次启动时确保全局配置文件存在(带注释模板)
+ await Entry.StartupWorkflow.EnsureConfigFilesExistAsync(fs);
+
var config = await App.Builder.ApplicationBuilder.LoadConfigAsync(options, fs);
var builder = new App.Builder.ApplicationBuilder()
diff --git a/tests/MockServers/Sync.Integration.Tests/ServiceRegistrationIntegrationTests.cs b/tests/MockServers/Sync.Integration.Tests/ServiceRegistrationIntegrationTests.cs
index 5408deb8..312896bd 100644
--- a/tests/MockServers/Sync.Integration.Tests/ServiceRegistrationIntegrationTests.cs
+++ b/tests/MockServers/Sync.Integration.Tests/ServiceRegistrationIntegrationTests.cs
@@ -3,7 +3,7 @@ namespace Tests;
[Trait("Category", "Integration")]
public class ServiceRegistrationIntegrationTests
{
- private static readonly string DefaultOpenAiModelId = ModelConfigLoader.GetDefaultModelId("openai");
+ private static readonly string DefaultModelId = ModelConfigLoader.GetDefaultModelId("deepseek");
[Fact]
public void AddWorkflowServices_ShouldRegisterITranscriptService()
@@ -42,7 +42,7 @@ public void FastModeService_ShouldUsePrimaryModelIdFromConfig()
var sp = services.BuildServiceProvider();
var fastModeService = sp.GetRequiredService();
- Assert.Equal(DefaultOpenAiModelId, fastModeService.PrimaryModelId);
+ Assert.Equal(DefaultModelId, fastModeService.PrimaryModelId);
}
[Fact]
diff --git a/tests/Unit/Host.Tests/ChatCommands/InitCommandTests.cs b/tests/Unit/Host.Tests/ChatCommands/InitCommandTests.cs
index 5294a738..cf8121a3 100644
--- a/tests/Unit/Host.Tests/ChatCommands/InitCommandTests.cs
+++ b/tests/Unit/Host.Tests/ChatCommands/InitCommandTests.cs
@@ -4,9 +4,7 @@ namespace Host.Tests.ChatCommands;
///
/// InitCommand 单元测试 — 覆盖 /init quick 路径
-/// 验证目标:bug 修复 — 使用 AppDataConstants.AppDataFolder 而非硬编码 ".jcc"
-/// 修复前:QuickInitAsync 硬编码 Path.Combine(cwd, ".jcc"),与 EnsureJccDirectory 使用 AppDataConstants.AppDataFolder (默认 "jcc") 路径不一致
-/// 修复后:统一使用 AppDataConstants.AppDataFolder,确保目录创建与文件写入路径一致
+/// 验证目标:统一使用 AppDataConstants.AppDataFolder 创建目录和写入文件
///
public sealed class InitCommandTests
{
@@ -41,14 +39,11 @@ public void IsHidden_Should_Be_False()
[Fact]
public async Task Execute_WithQuick_Should_Create_JccDirectory_Using_AppDataConstants()
{
- // Arrange — bug 复现:修复前硬编码 ".jcc",导致目录创建在 "jcc" 但文件写入 ".jcc"
- // 修复后应统一使用 AppDataConstants.AppDataFolder (默认 "jcc")
var fs = new InMemoryFileSystem();
var cwd = "/test/init-unit";
fs.SetCurrentDirectory(cwd);
var expectedJccDir = Path.Combine(cwd, AppDataConstants.AppDataFolder);
- var legacyJccDir = Path.Combine(cwd, ".jcc");
var cmd = new InitCommand();
var context = BuildContext("quick", fs);
@@ -60,15 +55,12 @@ public async Task Execute_WithQuick_Should_Create_JccDirectory_Using_AppDataCons
result.ShouldContinue.Should().BeTrue();
result.IsHandled.Should().BeTrue();
fs.DirectoryExists(expectedJccDir).Should().BeTrue(
- $"应使用 AppDataConstants.AppDataFolder ('{AppDataConstants.AppDataFolder}') 创建目录,而非硬编码 '.jcc'");
- fs.DirectoryExists(legacyJccDir).Should().BeFalse(
- "不应创建硬编码的 '.jcc' 目录 (bug 修复前会同时存在两个目录)");
+ $"应使用 AppDataConstants.AppDataFolder ('{AppDataConstants.AppDataFolder}') 创建目录");
}
[Fact]
public async Task Execute_WithQuick_Should_Write_Files_To_AppDataConstants_Path()
{
- // Arrange — 验证文件写入路径与目录创建路径一致
var fs = new InMemoryFileSystem();
var cwd = "/test/init-files";
fs.SetCurrentDirectory(cwd);
@@ -76,7 +68,6 @@ public async Task Execute_WithQuick_Should_Write_Files_To_AppDataConstants_Path(
var expectedJccDir = Path.Combine(cwd, AppDataConstants.AppDataFolder);
var expectedRulesFile = Path.Combine(expectedJccDir, "project_rules.md");
var expectedSettingsFile = Path.Combine(expectedJccDir, "settings.json");
- var legacyRulesFile = Path.Combine(cwd, ".jcc", "project_rules.md");
var cmd = new InitCommand();
var context = BuildContext("quick", fs);
@@ -89,14 +80,12 @@ public async Task Execute_WithQuick_Should_Write_Files_To_AppDataConstants_Path(
"project_rules.md 应写入 AppDataConstants.AppDataFolder 路径");
fs.FileExists(expectedSettingsFile).Should().BeTrue(
"settings.json 应写入 AppDataConstants.AppDataFolder 路径");
- fs.FileExists(legacyRulesFile).Should().BeFalse(
- "不应写入硬编码 '.jcc' 路径 (bug 修复前文件写入失败,因为目录创建在 'jcc' 而非 '.jcc')");
// 验证文件内容非空
var rulesContent = fs.ReadAllText(expectedRulesFile);
rulesContent.Should().Contain("项目规则");
var settingsContent = fs.ReadAllText(expectedSettingsFile);
- settingsContent.Should().Contain("openai");
+ settingsContent.Should().Contain("deepseek");
}
[Fact]
diff --git a/tests/Unit/Testing.Common/Services/InMemoryFileOperationService.cs b/tests/Unit/Testing.Common/Services/InMemoryFileOperationService.cs
index 148c8042..1ba5ee1b 100644
--- a/tests/Unit/Testing.Common/Services/InMemoryFileOperationService.cs
+++ b/tests/Unit/Testing.Common/Services/InMemoryFileOperationService.cs
@@ -336,6 +336,8 @@ public DateTime GetDirectoryLastWriteTimeUtc(string directoryPath)
return DateTime.UtcNow;
}
+ public void SetDirectoryLastWriteTimeUtc(string directoryPath, DateTime utcTime) { }
+
public DateTime GetFileLastWriteTime(string filePath)
{
return _fileSystem.GetLastWriteTime(filePath);
diff --git a/tests/Unit/Testing.Common/Services/InMemoryFileSystem.cs b/tests/Unit/Testing.Common/Services/InMemoryFileSystem.cs
index 32059575..875a4a93 100644
--- a/tests/Unit/Testing.Common/Services/InMemoryFileSystem.cs
+++ b/tests/Unit/Testing.Common/Services/InMemoryFileSystem.cs
@@ -518,6 +518,9 @@ public void MoveDirectory(string sourceDir, string destDir)
public DateTime GetDirectoryLastWriteTimeUtc(string path)
=> DateTime.UtcNow;
+ ///
+ public void SetDirectoryLastWriteTimeUtc(string path, DateTime utcTime) { }
+
///
public string? GetParentPath(string path)
{