@alpsckr/unitycli 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +109 -300
  2. package/contracts/init-contract.json +57 -0
  3. package/contracts/instances-contract.json +49 -0
  4. package/contracts/package-assembly-closure.json +33 -0
  5. package/contracts/package-files.json +1953 -0
  6. package/dist/bridge/session-supervisor.d.ts +2 -0
  7. package/dist/bridge/session-supervisor.js +52 -19
  8. package/dist/bridge/session-supervisor.js.map +1 -1
  9. package/dist/cli-commands/command.js +33 -15
  10. package/dist/cli-commands/command.js.map +1 -1
  11. package/dist/cli-commands/init-discovery.d.ts +4 -0
  12. package/dist/cli-commands/init-discovery.js +175 -0
  13. package/dist/cli-commands/init-discovery.js.map +1 -0
  14. package/dist/cli-commands/init-help.d.ts +3 -0
  15. package/dist/cli-commands/init-help.js +37 -0
  16. package/dist/cli-commands/init-help.js.map +1 -0
  17. package/dist/cli-commands/init-skill.d.ts +10 -0
  18. package/dist/cli-commands/init-skill.js +105 -0
  19. package/dist/cli-commands/init-skill.js.map +1 -0
  20. package/dist/cli-commands/instances.d.ts +4 -0
  21. package/dist/cli-commands/instances.js +90 -0
  22. package/dist/cli-commands/instances.js.map +1 -0
  23. package/dist/cli-commands/package.d.ts +2 -2
  24. package/dist/cli-commands/package.js +57 -137
  25. package/dist/cli-commands/package.js.map +1 -1
  26. package/dist/cli-commands/skill-installer.d.ts +14 -6
  27. package/dist/cli-commands/skill-installer.js +37 -38
  28. package/dist/cli-commands/skill-installer.js.map +1 -1
  29. package/dist/cli-commands/types.d.ts +1 -0
  30. package/dist/cli.d.ts +1 -1
  31. package/dist/cli.js +44 -37
  32. package/dist/cli.js.map +1 -1
  33. package/dist/contracts/command-contract.js +1 -0
  34. package/dist/contracts/command-contract.js.map +1 -1
  35. package/dist/doctor.js +1 -1
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/instances.d.ts +1 -1
  38. package/dist/instances.js +63 -38
  39. package/dist/instances.js.map +1 -1
  40. package/dist/output.d.ts +4 -4
  41. package/dist/output.js +3 -4
  42. package/dist/output.js.map +1 -1
  43. package/dist/package-manager.d.ts +43 -2
  44. package/dist/package-manager.js +255 -16
  45. package/dist/package-manager.js.map +1 -1
  46. package/dist/tools/.generated-schemas.js +2 -2
  47. package/dist/tools/.generated-schemas.js.map +1 -1
  48. package/dist/tools/commands.js +11 -4
  49. package/dist/tools/commands.js.map +1 -1
  50. package/dist/tools/dispatcher.js +1 -1
  51. package/dist/tools/dispatcher.js.map +1 -1
  52. package/dist/tools/tools/group-list.d.ts +4 -8
  53. package/dist/tools/tools/group-list.js +12 -13
  54. package/dist/tools/tools/group-list.js.map +1 -1
  55. package/dist/tools/tools/pagination.d.ts +17 -0
  56. package/dist/tools/tools/pagination.js +28 -0
  57. package/dist/tools/tools/pagination.js.map +1 -0
  58. package/dist/tools/tools/search.d.ts +3 -5
  59. package/dist/tools/tools/search.js +12 -12
  60. package/dist/tools/tools/search.js.map +1 -1
  61. package/docs/README.md +7 -4
  62. package/docs/architecture.md +103 -0
  63. package/docs/availability-differences.md +1 -1
  64. package/docs/command-execution.md +6 -1
  65. package/docs/development.md +91 -0
  66. package/docs/error-codes.md +5 -5
  67. package/docs/extensions.md +11 -13
  68. package/docs/target-and-instance.md +6 -4
  69. package/docs/tool-discovery.md +12 -10
  70. package/docs/tool-execution.md +3 -2
  71. package/package.json +10 -4
  72. package/schemas/init.schema.json +124 -0
  73. package/schemas/instances.schema.json +104 -0
  74. package/skills/unitycli/SKILL.md +18 -6
  75. package/skills/unitycli/manifest.json +2 -2
  76. package/skills/unitycli/references/custom-tools.md +3 -3
  77. package/skills/unitycli/references/operation-protocol.md +6 -4
  78. package/skills/unitycli/reports/output_quality_scorecard.md +1 -1
  79. package/skills/unitycli/reports/trust-report.md +1 -1
  80. package/unitypkg/Editor/Bridge/BridgeCommandRegistry.cs +1 -0
  81. package/unitypkg/Editor/Bridge/BridgeWorkQueue.cs +18 -6
  82. package/unitypkg/Editor/Infrastructure/CodeExecSupport.cs +8 -77
  83. package/unitypkg/Editor/Infrastructure/UnityCliInstanceCleanup.cs +295 -0
  84. package/unitypkg/Editor/Infrastructure/UnityCliInstanceRegistry.cs +166 -10
  85. package/unitypkg/Editor/Infrastructure/UnityCliOperationCoordinator.cs +1 -0
  86. package/unitypkg/Editor/UnityCliBridge.cs +5 -1
  87. package/unitypkg/Tests/Editor/UnityCliInstanceCleanupTests.cs +232 -0
  88. package/unitypkg/Tests/Editor/UnityCliInstancePublicationRetryTests.cs +143 -0
  89. package/unitypkg/UnityCli.Editor.asmdef +1 -5
  90. package/unitypkg/package.json +1 -1
  91. package/unitypkg/Editor/Roslyn/LICENSE.txt +0 -23
  92. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.CSharp.dll +0 -0
  93. package/unitypkg/Editor/Roslyn/Microsoft.CodeAnalysis.dll +0 -0
  94. package/unitypkg/Editor/Roslyn/System.Collections.Immutable.dll +0 -0
  95. package/unitypkg/Editor/Roslyn/System.Reflection.Metadata.dll +0 -0
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "unitycli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "owner": "alps-unity-cli maintainers",
5
- "updated_at": "2026-08-13",
5
+ "updated_at": "2026-08-27",
6
6
  "status": "active",
7
7
  "maturity_tier": "governed",
8
8
  "lifecycle_stage": "governed",
@@ -96,16 +96,16 @@ AI 使用当前实例的统一公开目录自发现和执行:
96
96
 
97
97
  ```powershell
98
98
  unitycli tools group --instance <id|projectPath|alias>
99
- unitycli tools group list <group> --instance <id|projectPath|alias>
99
+ unitycli tools group list <group> --page-size 20 --instance <id|projectPath|alias>
100
100
  unitycli tools describe <custom-tool> --instance <id|projectPath|alias>
101
101
  unitycli tools run <custom-tool> --input '<json>' --instance <id|projectPath|alias>
102
102
  ```
103
103
 
104
- `<group>` 必须来自当前实例 `tools group` 的真实输出;不要假设项目 Tool 位于 `custom` 组。`source` 只作描述/审计,不改变发现或执行路径。
104
+ `<group>` 必须来自当前实例 `tools group` 的真实输出;不要假设项目 Tool 位于 `custom` 组。存在`pageInfo.nextCursor`时必须继续读取所需页面。`source` 只作内部描述/审计,不改变发现或执行路径。
105
105
 
106
106
  ## 最小验证
107
107
 
108
108
  1. Unity 编译无错误。
109
- 2. `tools group` 与 `tools group list <group>` 能从同一实例完整目录发现 Tool。
109
+ 2. `tools group` 与 `tools group list <group>` 的分页结果能从同一实例完整目录发现 Tool。
110
110
  3. `tools describe <tool>` 的 schema、风险、target、Play Mode 语义足以让陌生 AI 一次调用成功。
111
111
  4. `tools run <tool> --input '<json>' --instance <id|projectPath|alias>` 覆盖默认正向、代表性可选参数和错误路径。
@@ -6,9 +6,11 @@
6
6
 
7
7
  `--instance <full-id|canonical-project-path|alias>`是通用显式实例选择器;`command --project-path <path>`是同一canonical identity选择语法。显式空值必须失败;业务`input.instance`不是selector。未传selector时只从当前Unity项目CWD解析;无法确定实例即稳定失败,`--project`和Tool input都不能选实例。
8
8
 
9
- `target alias` 只维护 canonical project identity alias。`source` 只供说明和审计,不影响显示、describe、owner 路由或执行。连接实例的 Tool 目录是权威目录;离线静态 catalog 不能替代它。PowerShell中执行多token搜索时使用`tools search '<k1|k2|...>'`;单引号必须包住完整关键词,避免`|`被解释为shell管道。
9
+ `target alias` 只维护 canonical project identity alias。`source` 只供说明和审计,不影响显示、describe、owner 路由或执行,也不进入搜索结果。连接实例的 Tool 目录是权威目录;离线静态 catalog 不能替代它。PowerShell中执行多token搜索时使用`tools search '<k1|k2|...>'`;单引号必须包住完整关键词,避免`|`被解释为shell管道。
10
10
 
11
- `instances list`逐个验证当前Registry v3正式文件。旧格式、损坏或不可读文件不参与路由,也不阻断其他合法实例;存在时结果包含`invalidRecordCount`,应使用`doctor`读取计数告警。不得读取、兼容解析、转换、移动或删除内部文件。显式选择对应坏instanceId仍严格失败。
11
+ `tools group list`、`tools search`和省略name的`command`列表按名称稳定分页,默认20项、最大100项。存在`pageInfo.nextCursor`时,将其原值传回同一查询的`--cursor`;不要重新解释或递增cursor。stdout默认是单行紧凑JSON,`--pretty`只增加缩进。
12
+
13
+ `instances list`逐个验证当前正式实例记录。旧格式、损坏或不可读文件不参与路由,也不阻断其他合法实例;存在时结果包含`invalidRecordCount`。当前项目由Unity在ready时自动重建。需要清理历史记录时调用`instances prune --instance <id|projectPath|alias>`并检查`removed`、`remaining`、`deferred`、`scanned`和`truncated`;不得读取、兼容解析、转换、移动或手工删除内部文件。
12
14
 
13
15
  ## Descriptor
14
16
 
@@ -46,7 +48,7 @@ Tool descriptor 常用字段:
46
48
 
47
49
  每个live Editor或Development Player session拥有独立任务池,默认达到5个active root时关闭准入,降到4仍关闭,降到3才重开。CLI在业务请求前自动等待;AI不手工轮询、不读取active count,也不管理内部token。统一catalog在Editor和Runtime同时在线时,会分别经过两个session的准入后才返回完整结果。
48
50
 
49
- `--timeout-ms T`先提供一份最多`T`的准入预算。准入deadline的`E_UNITY_BUSY`明确证明业务Bridge请求未发送、effect未开始,可以稍后提交新root。获得grant后再创建一份完整`T`的execution预算;token重取、status、rebind、reload和terminal recovery都只能消费该预算,不能刷新第三份预算。
51
+ `--timeout-ms T`先提供一份最多`T`的准入预算。CLI在其中等待首次ready记录、当前编译/导入/重载和任务池;证明Unity未运行时立即返回。准入deadline的`E_UNITY_BUSY`明确证明业务Bridge请求未发送、effect未开始,可以稍后提交新root。获得grant后再创建一份完整`T`的execution预算;token重取、status、rebind、reload和terminal recovery都只能消费该预算,不能刷新第三份预算。
50
52
 
51
53
  `E_EXECUTION_TIMEOUT`、放行后的transport丢失、进程中断或没有可信terminal envelope时,只能确定本次调用没有返回可信终态,不能据此判断mutation是否发生。先读取对象、场景、Package、build/test状态或artifact manifest;只有形成新的业务决策后才提交新请求,不把outcome unknown自动重放。
52
54
 
@@ -80,4 +82,4 @@ unitycli extensions remove <id> --project <path>
80
82
  unitycli extensions doctor --project <path>
81
83
  ```
82
84
 
83
- Skill/runtime bundle 安装使用 `init`;`init --check` 只读校验。Unity project 文件投影完成后,如任务要求 Editor 效果,仍需在已连接实例中完成 import/compile 和业务 readback
85
+ 安装协议先使用`unitycli init help`学习,再由`init discover`只读发现现场。Bridge只用`init package --project <UnityProject>`安装,并用同命令加`--check`校验;Skill只用`init skill --agent <.agents|claude> --scope <workspace|user>`,workspace scope还必须传`--workspace <path>`,同命令加`--check`校验。不得使用裸`init`或混装Bridge与Skill。Unity project文件投影完成后,如任务要求Editor效果,仍需在已连接实例中完成import/compile和业务readback;安装协议本身不得管理Unity进程。
@@ -24,7 +24,7 @@ The feedback output contract requires category, concise issue summary, optional
24
24
  | Feedback privacy negatives | PASS | Secret/endpoint/path negative matrix | Novel secret formats can still exist |
25
25
  | Concurrent JSONL validity | PASS | 16 parallel Windows PowerShell writers | Cross-session Windows desktop contention not measured |
26
26
  | Rolling bound | PASS | Main + 3 archives, each <= 64 KiB | Longitudinal retention utility not measured |
27
- | Installed bundle reachability | PASS | Public init/drifted-update/check/recorder smoke for all three agents | Verified on the selected Dev project only |
27
+ | Installed bundle reachability | PASS | Public workspace/user install, drifted-update, check, and recorder smoke for `.agents` and Claude | Verified with isolated filesystem targets |
28
28
  | Clean Agent interpretation | PASS | Black-box runtime discovery transcript and main-agent audit | Human adjudication unavailable |
29
29
 
30
30
  ## Rollback Boundary
@@ -36,7 +36,7 @@ The Skill does not add confirmation, intent review, or a predefined operation ra
36
36
 
37
37
  - Full repository tests: 876/876 PASS; targeted Skill/installer/docs regression: 61/61 PASS.
38
38
  - Recorder concurrency, rotation, privacy negatives, PowerShell 5.1 BOM, and non-ASCII stdout: PASS.
39
- - Public runtime install, drifted update, check, marker preservation, and installed-recorder smoke across `.pi`, `.claude`, and `.agents`: PASS.
39
+ - Public runtime install, drifted update, check, marker preservation, and installed-recorder smoke across `.claude` and `.agents`: PASS.
40
40
  - Resource boundary: 963 estimated initial-load tokens (691 Skill body tokens), limit 1300.
41
41
  - Trigger fixture gate: 29/29 classifications.
42
42
  - Output assertion gate: 5/5 with-skill cases, 100% pass rate.
@@ -86,6 +86,7 @@ namespace UnityCli
86
86
  RegisterInstalledExtensionCommands(registry);
87
87
  RegisterCodeExecCommand(registry);
88
88
  RegisterEditorStateSnapshotCommand(registry);
89
+ RegisterInstanceCleanupCommand(registry);
89
90
  UnityCliCompileCoordinator.Register(registry);
90
91
  UnityCliLiveBuildCoordinator.Register(registry);
91
92
  UnityCliLiveRefreshCoordinator.Register(registry);
@@ -60,22 +60,34 @@ namespace UnityCli
60
60
  // pulse只证明EditorApplication.update最近推进;具体请求仍由TryStart证明开始。
61
61
  Volatile.Write(ref mainThreadPulseTimestamp, System.Diagnostics.Stopwatch.GetTimestamp());
62
62
  Interlocked.Increment(ref mainThreadPulseSequence);
63
- cachedIsCompiling = EditorApplication.isCompiling;
64
- cachedIsUpdating = EditorApplication.isUpdating;
63
+ var listenerWasStarted = started;
64
+ var isCompiling = EditorApplication.isCompiling;
65
+ var isUpdating = EditorApplication.isUpdating;
66
+ var isBuilding = BuildPipeline.isBuildingPlayer;
67
+ var isPlayModeChanging = IsPlayModeChanging();
68
+ var lifecycleChanged = cachedIsCompiling != isCompiling
69
+ || cachedIsUpdating != isUpdating
70
+ || cachedIsBuilding != isBuilding
71
+ || cachedIsPlayModeChanging != isPlayModeChanging;
72
+ cachedIsCompiling = isCompiling;
73
+ cachedIsUpdating = isUpdating;
65
74
  cachedIsPlayingOrWillChangePlaymode = EditorApplication.isPlayingOrWillChangePlaymode;
66
75
  cachedIsPlaying = EditorApplication.isPlaying;
67
- cachedIsBuilding = BuildPipeline.isBuildingPlayer;
68
- cachedIsPlayModeChanging = IsPlayModeChanging();
76
+ cachedIsBuilding = isBuilding;
77
+ cachedIsPlayModeChanging = isPlayModeChanging;
69
78
  AdvanceBridgeStartup();
70
79
  UnityCliSessionMutationLease.TryFinalizeOwnedPlayModeRoot();
71
80
  UnityCliWaitScheduler.Tick();
72
81
 
73
- // 生命周期变化会即时登记;这里仅低频刷新,修复漏事件并更新诊断快照。
74
- if (started && EditorApplication.timeSinceStartup >= nextRegistryRefreshAt)
82
+ // 新listener先保留至少一个主线程update的transitioning状态;后续生命周期变化即时发布,30秒刷新只修复漏事件。
83
+ if (listenerWasStarted && started
84
+ && (readyPublicationPending || lifecycleChanged || EditorApplication.timeSinceStartup >= nextRegistryRefreshAt))
75
85
  {
86
+ readyPublicationPending = false;
76
87
  RegisterInstance("ready");
77
88
  nextRegistryRefreshAt = EditorApplication.timeSinceStartup + RegistryRefreshSeconds;
78
89
  }
90
+ TickInstancePublicationRetry();
79
91
  var startedThisTick = 0;
80
92
  while (startedThisTick < MaxWorkStartsPerTick)
81
93
  {
@@ -11,8 +11,6 @@ using System.Text;
11
11
  using System.Text.RegularExpressions;
12
12
  using System.Threading;
13
13
  using System.Threading.Tasks;
14
- using Microsoft.CodeAnalysis;
15
- using Microsoft.CodeAnalysis.CSharp;
16
14
  using UnityEditor;
17
15
  using UnityEditor.SceneManagement;
18
16
  using UnityEngine;
@@ -34,9 +32,6 @@ namespace UnityCli
34
32
  private const int MaxRoslynOutputBytes = 512 * 1024;
35
33
  private const int MaxRoslynDiagnostics = 100;
36
34
 
37
- // code.exec 编译核心开关:默认 in-process Roslyn(官方方案);置 false 回退到 csc.exe 子进程。
38
- private const bool CodeExecUseInProcessRoslyn = true;
39
-
40
35
  // 注册运行时代码执行命令。
41
36
  private static void RegisterCodeExecCommand(BridgeCommandRegistry registry)
42
37
  {
@@ -90,9 +85,7 @@ namespace UnityCli
90
85
  byte[] assemblyBytes;
91
86
  try
92
87
  {
93
- var compilation = CodeExecUseInProcessRoslyn
94
- ? CompileInProcess(source, references, request.StringArg("defines"), request, cancellationToken)
95
- : CompileWithCscFallback(source, references, request.StringArg("defines"), request, cancellationToken);
88
+ var compilation = CompileWithUnityCompiler(source, references, request.StringArg("defines"), request, cancellationToken);
96
89
  if (compilation.TimedOut)
97
90
  return CodeExecutionResult.Fail("E_EXECUTION_TIMEOUT", "Runtime Code compilation exceeded the immutable deadline.", new Dictionary<string, object>(), "Inspect the Editor state before submitting a new request.");
98
91
  if (!compilation.Success)
@@ -215,8 +208,8 @@ namespace UnityCli
215
208
  }
216
209
 
217
210
  /// <summary>
218
- /// 汇总 in-process Roslyn 所需引用;跟随官方 Editor 模式全包含 AppDomain 已加载程序集,
219
- /// 不排除 mscorlib/System*/netstandard,避免针对 mscorlib 编译的项目程序集缺核心库引用。
211
+ /// 汇总 Unity Editor 编译器所需引用;全包含 AppDomain 已加载程序集,
212
+ /// 不排除 mscorlib/System*/netstandard,避免项目程序集缺少核心库引用。
220
213
  /// </summary>
221
214
  private static IEnumerable<string> DefaultCodeReferences(string refsCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
222
215
  {
@@ -245,7 +238,7 @@ namespace UnityCli
245
238
  if (!references.Contains(reference)) references.Add(reference);
246
239
  }
247
240
 
248
- // in-process csc.exe 回退共用的编译结果;成功时携带程序集字节,失败时携带错误诊断。
241
+ // Unity Editor 编译器结果;成功时携带程序集字节,失败时携带结构化诊断。
249
242
  private sealed class CodeCompilationResult
250
243
  {
251
244
  public bool Success;
@@ -255,71 +248,9 @@ namespace UnityCli
255
248
  }
256
249
 
257
250
  /// <summary>
258
- /// in-process Roslyn(Microsoft.CodeAnalysis.CSharp)编译源码。
259
- /// 引用集全包含 AppDomain 已加载程序集,与官方 Editor 模式一致;
260
- /// 编译无法中途终止,只能在编译前后重验 immutable deadline。
261
- /// </summary>
262
- private static CodeCompilationResult CompileInProcess(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
263
- {
264
- var result = new CodeCompilationResult();
265
- try
266
- {
267
- EnsureCodePreEffectDeadline(request, cancellationToken);
268
- var parseOptions = new CSharpParseOptions(preprocessorSymbols: SplitCsv(definesCsv));
269
- var tree = CSharpSyntaxTree.ParseText(WrapCodeSource(source), parseOptions);
270
- var metadataReferences = new List<MetadataReference>();
271
- foreach (var reference in references)
272
- {
273
- if (string.IsNullOrEmpty(reference)) continue;
274
- try { metadataReferences.Add(MetadataReference.CreateFromFile(reference)); }
275
- catch { /* 跳过不可读引用,避免个别文件缺失中断整次编译 */ }
276
- }
277
- var compilation = CSharpCompilation.Create(
278
- "UnityCli.RuntimeCode",
279
- new[] { tree },
280
- metadataReferences,
281
- new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
282
- using (var ms = new MemoryStream())
283
- {
284
- var emitResult = compilation.Emit(ms);
285
- foreach (var diagnostic in emitResult.Diagnostics)
286
- {
287
- if (diagnostic.Severity != DiagnosticSeverity.Error) continue;
288
- if (result.Diagnostics.Count >= MaxRoslynDiagnostics) continue;
289
- var lineSpan = diagnostic.Location.GetLineSpan();
290
- result.Diagnostics.Add(new Dictionary<string, object>
291
- {
292
- { "severity", "error" }, { "id", BoundedText(diagnostic.Id, 64) },
293
- { "message", BoundedText(diagnostic.GetMessage(), 4096) },
294
- { "file", BoundedText(lineSpan.Path ?? string.Empty, 2048) },
295
- { "line", (long)(lineSpan.StartLinePosition.Line + 1) },
296
- { "column", (long)(lineSpan.StartLinePosition.Character + 1) },
297
- { "span", null }
298
- });
299
- }
300
- result.Success = emitResult.Success;
301
- if (emitResult.Success) result.AssemblyBytes = ms.ToArray();
302
- }
303
- // in-process 无法中途终止;编译完成后重验 deadline,拒绝超时结果。
304
- int remainingMs;
305
- if (cancellationToken.IsCancellationRequested || request == null || !request.TryDeadlineRemainingMs(out remainingMs))
306
- {
307
- result.TimedOut = true;
308
- result.Success = false;
309
- result.AssemblyBytes = null;
310
- }
311
- }
312
- catch (OperationCanceledException)
313
- {
314
- result.TimedOut = true;
315
- }
316
- return result;
317
- }
318
-
319
- /// <summary>
320
- /// csc.exe 子进程编译回退;保留原临时文件与子进程超时终止逻辑,仅在 in-process 编译不可用时切换回来。
251
+ /// 使用当前 Unity Editor 自带的 csc.exe 编译源码;子进程受请求 deadline 约束并可终止。
321
252
  /// </summary>
322
- private static CodeCompilationResult CompileWithCscFallback(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
253
+ private static CodeCompilationResult CompileWithUnityCompiler(string source, IEnumerable<string> references, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
323
254
  {
324
255
  var result = new CodeCompilationResult();
325
256
  var editorDataPath = EditorDataPath();
@@ -339,7 +270,7 @@ namespace UnityCli
339
270
  {
340
271
  File.WriteAllText(sourceFilePath, WrapCodeSource(source), new UTF8Encoding(true));
341
272
  WriteReferenceResponseFile(responseFilePath, references);
342
- var compilation = CompileWithRoslyn(editorDataPath, sourceFilePath, responseFilePath, assemblyFilePath, definesCsv, request, cancellationToken);
273
+ var compilation = RunUnityCompiler(editorDataPath, sourceFilePath, responseFilePath, assemblyFilePath, definesCsv, request, cancellationToken);
343
274
  if (compilation.TimedOut)
344
275
  {
345
276
  result.TimedOut = true;
@@ -394,7 +325,7 @@ namespace UnityCli
394
325
  /// <summary>
395
326
  /// 使用 Unity 内置 Roslyn 编译临时源码;进程最多运行 60 秒,输出仅用于诊断,成功程序集由调用方读取到内存。
396
327
  /// </summary>
397
- private static RoslynCompilation CompileWithRoslyn(string editorDataPath, string sourceFilePath, string responseFilePath, string assemblyFilePath, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
328
+ private static RoslynCompilation RunUnityCompiler(string editorDataPath, string sourceFilePath, string responseFilePath, string assemblyFilePath, string definesCsv, UnityCli.Protocol.BridgeRequest request, CancellationToken cancellationToken)
398
329
  {
399
330
  var monoPath = Path.Combine(editorDataPath, "MonoBleedingEdge", "bin", "mono.exe");
400
331
  var cscPath = Path.Combine(editorDataPath, "MonoBleedingEdge", "lib", "mono", "4.5", "csc.exe");
@@ -0,0 +1,295 @@
1
+ #if UNITY_EDITOR
2
+ using System;
3
+ using System.Collections.Generic;
4
+ using System.Globalization;
5
+ using System.IO;
6
+ using System.Linq;
7
+ using System.Text;
8
+
9
+ namespace UnityCli
10
+ {
11
+ public static partial class UnityCliBridge
12
+ {
13
+ private const int InstanceCleanupMaxScanned = 2000;
14
+ private const int InstanceCleanupMaxDeleted = 20;
15
+ private const int InstanceCleanupRetainedOffline = 100;
16
+ private const int InstanceCleanupRetainedDamaged = 20;
17
+ private const int InstanceCleanupRetentionDays = 30;
18
+ private static bool instanceCleanupAttempted;
19
+ private static readonly UnityCliToolCompletionContract InstanceCleanupCompletion = new UnityCliToolCompletionContract(
20
+ "internal.instances.prune", "control-plane", "direct-terminal", "atomic", "bounded-cleanup-summary",
21
+ "writer-owned-registry-reinspection", "caller-responsibility", "E_EXECUTION_TIMEOUT", "caller-responsibility",
22
+ "atomic-typed-result-v1", "required", true);
23
+
24
+ private sealed class InstanceCleanupCandidate
25
+ {
26
+ public string Path;
27
+ public string InstanceId;
28
+ public DateTimeOffset LastActivity;
29
+ public bool Damaged;
30
+ }
31
+
32
+ private sealed class InstanceCleanupSummary
33
+ {
34
+ public int Scanned;
35
+ public int Offline;
36
+ public int Damaged;
37
+ public int Preserved;
38
+ public int Deleted;
39
+ public int Deferred;
40
+ public int Remaining;
41
+ public bool Truncated;
42
+ }
43
+
44
+ /// <summary>每个domain只尝试一次自动清理;其他Unity持有全局锁时直接让其负责。</summary>
45
+ private static void TryRunAutomaticInstanceCleanup()
46
+ {
47
+ if (instanceCleanupAttempted) return;
48
+ instanceCleanupAttempted = true;
49
+ try
50
+ {
51
+ InstanceCleanupSummary summary;
52
+ var acquired = TryExecuteInstanceCleanup(null, out summary);
53
+ if (!acquired)
54
+ {
55
+ WriteLog("instance_cleanup_deferred reason=global-lock");
56
+ return;
57
+ }
58
+ WriteLog("instance_cleanup_completed scanned=" + summary.Scanned.ToString(CultureInfo.InvariantCulture)
59
+ + " offline=" + summary.Offline.ToString(CultureInfo.InvariantCulture)
60
+ + " damaged=" + summary.Damaged.ToString(CultureInfo.InvariantCulture)
61
+ + " preserved=" + summary.Preserved.ToString(CultureInfo.InvariantCulture)
62
+ + " deleted=" + summary.Deleted.ToString(CultureInfo.InvariantCulture)
63
+ + " deferred=" + summary.Deferred.ToString(CultureInfo.InvariantCulture)
64
+ + " truncated=" + (summary.Truncated ? "true" : "false"));
65
+ }
66
+ catch (Exception exception)
67
+ {
68
+ WriteLog("instance_cleanup_failed kind=" + exception.GetType().Name);
69
+ }
70
+ }
71
+
72
+ /// <summary>跨Unity只允许一个清理owner;零等待失败由调用方延期或返回busy。</summary>
73
+ private static bool TryExecuteInstanceCleanup(Action beginEffect, out InstanceCleanupSummary summary)
74
+ {
75
+ InstanceCleanupSummary value = null;
76
+ var acquired = TryWithCurrentUserMutex("Local\\UnityCli.Registry.Cleanup", 0, delegate
77
+ {
78
+ if (beginEffect != null) beginEffect();
79
+ value = RunInstanceCleanup();
80
+ });
81
+ summary = value;
82
+ return acquired;
83
+ }
84
+
85
+ /// <summary>扫描和删除均有硬上限;活跃、不可确认、链接或锁竞争记录只保留。</summary>
86
+ private static InstanceCleanupSummary RunInstanceCleanup()
87
+ {
88
+ var summary = new InstanceCleanupSummary();
89
+ var directory = InstancesDir();
90
+ if (!Directory.Exists(directory)) return summary;
91
+ var root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
92
+ var candidates = new List<InstanceCleanupCandidate>();
93
+ using (var enumerator = Directory.EnumerateFiles(root, "*.json", SearchOption.TopDirectoryOnly).GetEnumerator())
94
+ {
95
+ while (summary.Scanned < InstanceCleanupMaxScanned && enumerator.MoveNext())
96
+ {
97
+ var fullPath = Path.GetFullPath(enumerator.Current);
98
+ var parent = Path.GetDirectoryName(fullPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
99
+ var fileName = Path.GetFileName(fullPath);
100
+ string candidateInstanceId;
101
+ if (!string.Equals(parent, root, StringComparison.OrdinalIgnoreCase)
102
+ || !TryInstanceIdFromFileName(fileName, out candidateInstanceId)) continue;
103
+ summary.Scanned += 1;
104
+ if (string.Equals(candidateInstanceId, InstanceId(), StringComparison.Ordinal))
105
+ {
106
+ summary.Preserved += 1;
107
+ continue;
108
+ }
109
+ InstanceCleanupCandidate candidate;
110
+ if (!TryInspectCleanupCandidate(fullPath, candidateInstanceId, out candidate))
111
+ {
112
+ summary.Preserved += 1;
113
+ continue;
114
+ }
115
+ if (candidate.Damaged) summary.Damaged += 1;
116
+ else summary.Offline += 1;
117
+ candidates.Add(candidate);
118
+ }
119
+ if (enumerator.MoveNext()) summary.Truncated = true;
120
+ }
121
+
122
+ var cutoff = DateTimeOffset.UtcNow.AddDays(-InstanceCleanupRetentionDays);
123
+ var offline = candidates.Where(value => !value.Damaged).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).ToList();
124
+ var damaged = candidates.Where(value => value.Damaged).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).ToList();
125
+ var planned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
126
+ PlanCleanup(offline, InstanceCleanupRetainedOffline, cutoff, planned);
127
+ PlanCleanup(damaged, InstanceCleanupRetainedDamaged, cutoff, planned);
128
+ summary.Deferred += Math.Max(0, planned.Count - InstanceCleanupMaxDeleted);
129
+
130
+ foreach (var candidate in candidates.Where(value => planned.Contains(value.Path)).OrderBy(value => value.LastActivity).ThenBy(value => value.InstanceId, StringComparer.Ordinal).Take(InstanceCleanupMaxDeleted))
131
+ {
132
+ var deleted = false;
133
+ var locked = false;
134
+ try
135
+ {
136
+ locked = TryWithCurrentUserMutex("Local\\UnityCli.Registry." + candidate.InstanceId, 0, delegate
137
+ {
138
+ InstanceCleanupCandidate current;
139
+ if (!TryInspectCleanupCandidate(candidate.Path, candidate.InstanceId, out current)) return;
140
+ if (current.Damaged != candidate.Damaged || current.LastActivity > candidate.LastActivity) return;
141
+ File.Delete(candidate.Path);
142
+ deleted = true;
143
+ });
144
+ }
145
+ catch
146
+ {
147
+ deleted = false;
148
+ }
149
+ if (deleted) summary.Deleted += 1;
150
+ else if (!locked || File.Exists(candidate.Path)) summary.Deferred += 1;
151
+ }
152
+ summary.Remaining = Math.Max(0, summary.Scanned - summary.Deleted);
153
+ return summary;
154
+ }
155
+
156
+ private static void PlanCleanup(List<InstanceCleanupCandidate> ordered, int retained, DateTimeOffset cutoff, HashSet<string> planned)
157
+ {
158
+ var excess = Math.Max(0, ordered.Count - retained);
159
+ for (var index = 0; index < ordered.Count; index++)
160
+ {
161
+ if (index < excess || ordered[index].LastActivity <= cutoff) planned.Add(ordered[index].Path);
162
+ }
163
+ }
164
+
165
+ /// <summary>返回false表示活跃、不可确认、链接、占用或不安全路径,调用方必须保留。</summary>
166
+ private static bool TryInspectCleanupCandidate(string path, string expectedInstanceId, out InstanceCleanupCandidate candidate)
167
+ {
168
+ candidate = null;
169
+ try
170
+ {
171
+ var info = new FileInfo(path);
172
+ if (!info.Exists || (info.Attributes & FileAttributes.ReparsePoint) != 0) return false;
173
+ var fallbackActivity = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
174
+ if (info.Length > RegistryMaxBytes)
175
+ {
176
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
177
+ return true;
178
+ }
179
+
180
+ Dictionary<string, object> record;
181
+ try
182
+ {
183
+ record = UnityCliSerializer.Parse(File.ReadAllText(path, Encoding.UTF8)) as Dictionary<string, object>;
184
+ }
185
+ catch (FormatException)
186
+ {
187
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
188
+ return true;
189
+ }
190
+ if (record == null || ReadLong(record, "version") != 3L || ReadLong(record, "canonicalizationVersion") != 1L)
191
+ {
192
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
193
+ return true;
194
+ }
195
+ var identity = ReadDictionary(record, "identity");
196
+ var canonicalPath = ReadString(identity, "canonicalProjectPath");
197
+ var actualInstanceId = ReadString(identity, "instanceId");
198
+ if (string.IsNullOrEmpty(canonicalPath)
199
+ || !string.Equals(actualInstanceId, expectedInstanceId, StringComparison.Ordinal)
200
+ || !string.Equals("unity-" + Sha256Utf8(canonicalPath), expectedInstanceId, StringComparison.Ordinal))
201
+ {
202
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
203
+ return true;
204
+ }
205
+ object rawSessions;
206
+ var sessionValues = record.TryGetValue("sessions", out rawSessions) ? rawSessions as List<object> : null;
207
+ if (sessionValues == null)
208
+ {
209
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
210
+ return true;
211
+ }
212
+ var sessions = sessionValues.OfType<Dictionary<string, object>>().ToList();
213
+ if (sessions.Count != sessionValues.Count || sessions.Any(IsProcessClaimAliveOrUnverifiable)) return false;
214
+ DateTimeOffset publishedAt;
215
+ if (!DateTimeOffset.TryParse(ReadString(record, "publishedAt"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out publishedAt))
216
+ {
217
+ candidate = CleanupCandidate(path, expectedInstanceId, fallbackActivity, true);
218
+ return true;
219
+ }
220
+ candidate = CleanupCandidate(path, expectedInstanceId, publishedAt, false);
221
+ return true;
222
+ }
223
+ catch (IOException) { return false; }
224
+ catch (UnauthorizedAccessException) { return false; }
225
+ catch { return false; }
226
+ }
227
+
228
+ private static InstanceCleanupCandidate CleanupCandidate(string path, string instanceId, DateTimeOffset lastActivity, bool damaged)
229
+ {
230
+ return new InstanceCleanupCandidate { Path = path, InstanceId = instanceId, LastActivity = lastActivity, Damaged = damaged };
231
+ }
232
+
233
+ /// <summary>私有Bridge命令让公开instances prune只触发Unity writer,不把删除权交给CLI。</summary>
234
+ private static void RegisterInstanceCleanupCommand(BridgeCommandRegistry registry)
235
+ {
236
+ registry.Register<UnityCli.Protocol.BridgeRequest>(
237
+ "internal.instances.prune",
238
+ "internal",
239
+ true,
240
+ 30000,
241
+ (request, cancellationToken) =>
242
+ {
243
+ cancellationToken.ThrowIfCancellationRequested();
244
+ InstanceCleanupSummary summary;
245
+ if (!TryExecuteInstanceCleanup(delegate { BeginDirectEffect(request.RequestId); }, out summary))
246
+ throw new BridgeCommandException("E_UNITY_BUSY", "Another Unity instance is cleaning historical instance records.", null, new[] { "Retry unitycli instances prune after the current cleanup finishes." }, true, 100);
247
+ var result = new Dictionary<string, object>
248
+ {
249
+ { "removed", (long)summary.Deleted },
250
+ { "remaining", (long)summary.Remaining },
251
+ { "deferred", (long)summary.Deferred },
252
+ { "scanned", (long)summary.Scanned },
253
+ { "truncated", summary.Truncated },
254
+ { "retention", new Dictionary<string, object>
255
+ {
256
+ { "offlineMax", (long)InstanceCleanupRetainedOffline },
257
+ { "damagedMax", (long)InstanceCleanupRetainedDamaged },
258
+ { "maxAgeDays", (long)InstanceCleanupRetentionDays },
259
+ { "scanLimit", (long)InstanceCleanupMaxScanned },
260
+ { "deleteLimit", (long)InstanceCleanupMaxDeleted }
261
+ }
262
+ }
263
+ };
264
+ RecordDirectCompletionEvidence(request.RequestId, "internal.instances.prune", InstanceCleanupCompletion, result);
265
+ return new System.Threading.Tasks.ValueTask<object>(result);
266
+ });
267
+ }
268
+
269
+ private static bool IsInstanceCleanupCommand(string command)
270
+ {
271
+ return string.Equals(command, "internal.instances.prune", StringComparison.Ordinal);
272
+ }
273
+
274
+ private static UnityCliToolCompletionContract InstanceCleanupCompletionContract()
275
+ {
276
+ return InstanceCleanupCompletion;
277
+ }
278
+
279
+ private static bool TryInstanceIdFromFileName(string fileName, out string instanceId)
280
+ {
281
+ instanceId = null;
282
+ if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) return false;
283
+ var value = fileName.Substring(0, fileName.Length - 5);
284
+ if (!value.StartsWith("unity-", StringComparison.Ordinal) || value.Length != 70) return false;
285
+ for (var index = 6; index < value.Length; index++)
286
+ {
287
+ var ch = value[index];
288
+ if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) return false;
289
+ }
290
+ instanceId = value;
291
+ return true;
292
+ }
293
+ }
294
+ }
295
+ #endif