@atelierai/uco 1.0.9 → 1.0.10

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.10 - 2026-09-19
4
+
5
+ - Vendored plugin refreshed to **1.0.8** — fixes an editor-native crash on
6
+ domain reload (UmaViewer issue uco-domain-reload-crash-20260919, 2/2
7
+ reproducible during script recompile). The plugin no longer defines a
8
+ finalizer: the historical one ran the full teardown (token cancellation
9
+ with synchronous Task continuations and ExecutionContext restores) on the
10
+ GC finalizer thread during domain unload, which mono cannot execute.
11
+ Assembly-reload/unload/quit cleanup now disposes the plugin instance
12
+ deterministically on the safe background-thread path. Ten other
13
+ same-pattern finalizers (request DTOs, log storage/collector) removed with
14
+ it. Framework tests 831x2; 2022.3 gate unchanged.
15
+
16
+
3
17
  ## 1.0.9 - 2026-09-19
4
18
 
5
19
  - Vendored plugin refreshed to **1.0.7**: the complete legacy-naming sweep —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelierai/uco",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "uco — Unity Copilot CLI. Drive Unity Editor (and runtime game builds) from any AI agent via plain HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,6 +84,22 @@ namespace com.AtelierAI.Unity.Copilot.Editor
84
84
  nameof(Startup), callerName, e.Message);
85
85
  }
86
86
  }
87
+
88
+ // Deterministic teardown on reload/unload/quit: dispose the plugin
89
+ // instance on a background thread (the safe context for the token
90
+ // cancellation callbacks — same reasoning as
91
+ // DisposeUcoPluginInstance). Without this, the instance survived
92
+ // domain unload undisposed and relied on GC finalization — the
93
+ // crash path fixed in uco-domain-reload-crash-20260919.
94
+ try
95
+ {
96
+ plugin.DisposeUcoPluginInstance();
97
+ }
98
+ catch (System.Exception e)
99
+ {
100
+ _logger.LogWarning(e, "{class} {method}: Exception during plugin disposal (non-blocking): {message}",
101
+ nameof(Startup), callerName, e.Message);
102
+ }
87
103
  }
88
104
 
89
105
  try
@@ -1,207 +1,208 @@
1
- /*
2
- ┌──────────────────────────────────────────────────────────────────┐
3
- │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
- │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
- │ Copyright (c) 2025 Ivan Murzak │
6
- │ Licensed under the Apache License, Version 2.0. │
7
- │ See the LICENSE file in the project root for more information. │
8
- └──────────────────────────────────────────────────────────────────┘
9
- */
10
-
11
- #nullable enable
12
- using System;
13
- using System.Collections.Generic;
14
- using System.IO;
15
- using System.Linq;
16
- using System.Text.Json;
17
- using System.Threading.Tasks;
18
- using Microsoft.Extensions.Logging;
19
- using UnityEngine;
20
-
21
- namespace com.AtelierAI.Unity.Copilot
22
- {
23
- using ILogger = Microsoft.Extensions.Logging.ILogger;
24
- public class BufferedFileLogStorage : FileLogStorage
25
- {
26
- protected readonly int _flushEntriesThreshold;
27
- protected readonly LogEntry[] _logEntriesBuffer;
28
- protected int _logEntriesBufferLength;
29
-
30
- public BufferedFileLogStorage(
31
- ILogger? logger = null,
32
- int flushEntriesThreshold = 100,
33
- string? cacheFilePath = null,
34
- string? cacheFileName = null,
35
- int fileBufferSize = 4096,
36
- int maxFileSizeMB = DefaultMaxFileSizeMB,
37
- JsonSerializerOptions? jsonOptions = null)
38
- : base(logger, cacheFilePath, cacheFileName, fileBufferSize, maxFileSizeMB, jsonOptions)
39
- {
40
- if (flushEntriesThreshold <= 0)
41
- throw new ArgumentOutOfRangeException(nameof(flushEntriesThreshold), "Flush entries threshold must be greater than zero.");
42
-
43
- _flushEntriesThreshold = flushEntriesThreshold;
44
- _logEntriesBuffer = new LogEntry[flushEntriesThreshold];
45
- _logEntriesBufferLength = 0;
46
- }
47
-
48
- public override void Flush()
49
- {
50
- if (_isDisposed.Value)
51
- {
52
- _logger.LogWarning("{method} called but already disposed, ignored.",
53
- nameof(Flush));
54
- return;
55
- }
56
- lock (_fileMutex)
57
- {
58
- // Flush buffered entries to file
59
- if (_logEntriesBufferLength > 0)
60
- {
61
- var entriesToFlush = new LogEntry[_logEntriesBufferLength];
62
- Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
63
- base.AppendInternal(entriesToFlush);
64
- _logEntriesBufferLength = 0;
65
- }
66
- fileWriteStream?.Flush();
67
- }
68
- }
69
- public override Task FlushAsync()
70
- {
71
- if (_isDisposed.Value)
72
- {
73
- _logger.LogWarning("{method} called but already disposed, ignored.",
74
- nameof(FlushAsync));
75
- return Task.CompletedTask;
76
- }
77
- lock (_fileMutex)
78
- {
79
- // Flush buffered entries to file
80
- if (_logEntriesBufferLength > 0)
81
- {
82
- var entriesToFlush = new LogEntry[_logEntriesBufferLength];
83
- Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
84
- base.AppendInternal(entriesToFlush);
85
- _logEntriesBufferLength = 0;
86
- }
87
- fileWriteStream?.Flush();
88
- }
89
- return Task.CompletedTask;
90
- }
91
-
92
- protected override void AppendInternal(params LogEntry[] entries)
93
- {
94
- if (_isDisposed.Value)
95
- {
96
- _logger.LogWarning("{method} called but already disposed, ignored.",
97
- nameof(AppendInternal));
98
- return;
99
- }
100
- if (_logEntriesBufferLength >= _flushEntriesThreshold)
101
- {
102
- base.AppendInternal(_logEntriesBuffer);
103
- _logEntriesBufferLength = 0;
104
- }
105
- foreach (var entry in entries)
106
- {
107
- _logEntriesBuffer[_logEntriesBufferLength] = entry;
108
- _logEntriesBufferLength++;
109
-
110
- if (_logEntriesBufferLength >= _flushEntriesThreshold)
111
- {
112
- base.AppendInternal(_logEntriesBuffer);
113
- _logEntriesBufferLength = 0;
114
- }
115
- }
116
- }
117
-
118
- /// <summary>
119
- /// Closes and disposes the current file stream if open. Clears the log cache file.
120
- /// </summary>
121
- public override LogClearResult Clear()
122
- {
123
- if (_isDisposed.Value)
124
- {
125
- _logger.LogWarning("{method} called but already disposed, ignored.",
126
- nameof(Clear));
127
- return new LogClearResult
128
- {
129
- Ok = false,
130
- Strategy = "storage-disposed",
131
- Path = filePath,
132
- Error = "The log storage is disposed."
133
- };
134
- }
135
- lock (_fileMutex)
136
- {
137
- _logEntriesBufferLength = 0;
138
- return ClearFileLocked();
139
- }
140
- }
141
-
142
- public override LogEntry[] Query(
143
- int maxEntries = 100,
144
- LogType? logTypeFilter = null,
145
- bool includeStackTrace = false,
146
- int lastMinutes = 0)
147
- {
148
- if (_isDisposed.Value)
149
- {
150
- _logger.LogWarning("{method} called but already disposed, ignored.",
151
- nameof(Query));
152
- return Array.Empty<LogEntry>();
153
- }
154
- lock (_fileMutex)
155
- {
156
- return QueryInternal(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
157
- }
158
- }
159
-
160
- protected override LogEntry[] QueryInternal(
161
- int maxEntries = 100,
162
- LogType? logTypeFilter = null,
163
- bool includeStackTrace = false,
164
- int lastMinutes = 0)
165
- {
166
- var result = new List<LogEntry>();
167
- var cutoffTime = lastMinutes > 0
168
- ? System.DateTime.Now.AddMinutes(-lastMinutes)
169
- : System.DateTime.MinValue;
170
-
171
- // 1. Get from buffer (Newest are at the end of buffer)
172
- for (int i = _logEntriesBufferLength - 1; i >= 0; i--)
173
- {
174
- var entry = _logEntriesBuffer[i];
175
- if (logTypeFilter.HasValue && entry.LogType != logTypeFilter.Value)
176
- continue;
177
-
178
- if (lastMinutes > 0)
179
- {
180
- if (entry.Timestamp < cutoffTime)
181
- {
182
- return result.AsEnumerable().Reverse().ToArray();
183
- }
184
- }
185
-
186
- result.Add(entry);
187
- if (result.Count >= maxEntries)
188
- return result.AsEnumerable().Reverse().ToArray();
189
- }
190
-
191
- // 2. Exit if we already have enough entries
192
- var neededLogsCount = maxEntries - result.Count;
193
- if (neededLogsCount <= 0)
194
- return result.AsEnumerable().Reverse().ToArray();
195
-
196
- result.Reverse();
197
-
198
- // 3. Get from file
199
- var fileEntries = base.QueryInternal(neededLogsCount, logTypeFilter, includeStackTrace, lastMinutes);
200
- result.AddRange(fileEntries);
201
-
202
- return result.ToArray();
203
- }
204
-
205
- ~BufferedFileLogStorage() => Dispose();
206
- }
207
- }
1
+ /*
2
+ ┌──────────────────────────────────────────────────────────────────┐
3
+ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
+ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
+ │ Copyright (c) 2025 Ivan Murzak │
6
+ │ Licensed under the Apache License, Version 2.0. │
7
+ │ See the LICENSE file in the project root for more information. │
8
+ └──────────────────────────────────────────────────────────────────┘
9
+ */
10
+
11
+ #nullable enable
12
+ using System;
13
+ using System.Collections.Generic;
14
+ using System.IO;
15
+ using System.Linq;
16
+ using System.Text.Json;
17
+ using System.Threading.Tasks;
18
+ using Microsoft.Extensions.Logging;
19
+ using UnityEngine;
20
+
21
+ namespace com.AtelierAI.Unity.Copilot
22
+ {
23
+ using ILogger = Microsoft.Extensions.Logging.ILogger;
24
+ public class BufferedFileLogStorage : FileLogStorage
25
+ {
26
+ protected readonly int _flushEntriesThreshold;
27
+ protected readonly LogEntry[] _logEntriesBuffer;
28
+ protected int _logEntriesBufferLength;
29
+
30
+ public BufferedFileLogStorage(
31
+ ILogger? logger = null,
32
+ int flushEntriesThreshold = 100,
33
+ string? cacheFilePath = null,
34
+ string? cacheFileName = null,
35
+ int fileBufferSize = 4096,
36
+ int maxFileSizeMB = DefaultMaxFileSizeMB,
37
+ JsonSerializerOptions? jsonOptions = null)
38
+ : base(logger, cacheFilePath, cacheFileName, fileBufferSize, maxFileSizeMB, jsonOptions)
39
+ {
40
+ if (flushEntriesThreshold <= 0)
41
+ throw new ArgumentOutOfRangeException(nameof(flushEntriesThreshold), "Flush entries threshold must be greater than zero.");
42
+
43
+ _flushEntriesThreshold = flushEntriesThreshold;
44
+ _logEntriesBuffer = new LogEntry[flushEntriesThreshold];
45
+ _logEntriesBufferLength = 0;
46
+ }
47
+
48
+ public override void Flush()
49
+ {
50
+ if (_isDisposed.Value)
51
+ {
52
+ _logger.LogWarning("{method} called but already disposed, ignored.",
53
+ nameof(Flush));
54
+ return;
55
+ }
56
+ lock (_fileMutex)
57
+ {
58
+ // Flush buffered entries to file
59
+ if (_logEntriesBufferLength > 0)
60
+ {
61
+ var entriesToFlush = new LogEntry[_logEntriesBufferLength];
62
+ Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
63
+ base.AppendInternal(entriesToFlush);
64
+ _logEntriesBufferLength = 0;
65
+ }
66
+ fileWriteStream?.Flush();
67
+ }
68
+ }
69
+ public override Task FlushAsync()
70
+ {
71
+ if (_isDisposed.Value)
72
+ {
73
+ _logger.LogWarning("{method} called but already disposed, ignored.",
74
+ nameof(FlushAsync));
75
+ return Task.CompletedTask;
76
+ }
77
+ lock (_fileMutex)
78
+ {
79
+ // Flush buffered entries to file
80
+ if (_logEntriesBufferLength > 0)
81
+ {
82
+ var entriesToFlush = new LogEntry[_logEntriesBufferLength];
83
+ Array.Copy(_logEntriesBuffer, entriesToFlush, _logEntriesBufferLength);
84
+ base.AppendInternal(entriesToFlush);
85
+ _logEntriesBufferLength = 0;
86
+ }
87
+ fileWriteStream?.Flush();
88
+ }
89
+ return Task.CompletedTask;
90
+ }
91
+
92
+ protected override void AppendInternal(params LogEntry[] entries)
93
+ {
94
+ if (_isDisposed.Value)
95
+ {
96
+ _logger.LogWarning("{method} called but already disposed, ignored.",
97
+ nameof(AppendInternal));
98
+ return;
99
+ }
100
+ if (_logEntriesBufferLength >= _flushEntriesThreshold)
101
+ {
102
+ base.AppendInternal(_logEntriesBuffer);
103
+ _logEntriesBufferLength = 0;
104
+ }
105
+ foreach (var entry in entries)
106
+ {
107
+ _logEntriesBuffer[_logEntriesBufferLength] = entry;
108
+ _logEntriesBufferLength++;
109
+
110
+ if (_logEntriesBufferLength >= _flushEntriesThreshold)
111
+ {
112
+ base.AppendInternal(_logEntriesBuffer);
113
+ _logEntriesBufferLength = 0;
114
+ }
115
+ }
116
+ }
117
+
118
+ /// <summary>
119
+ /// Closes and disposes the current file stream if open. Clears the log cache file.
120
+ /// </summary>
121
+ public override LogClearResult Clear()
122
+ {
123
+ if (_isDisposed.Value)
124
+ {
125
+ _logger.LogWarning("{method} called but already disposed, ignored.",
126
+ nameof(Clear));
127
+ return new LogClearResult
128
+ {
129
+ Ok = false,
130
+ Strategy = "storage-disposed",
131
+ Path = filePath,
132
+ Error = "The log storage is disposed."
133
+ };
134
+ }
135
+ lock (_fileMutex)
136
+ {
137
+ _logEntriesBufferLength = 0;
138
+ return ClearFileLocked();
139
+ }
140
+ }
141
+
142
+ public override LogEntry[] Query(
143
+ int maxEntries = 100,
144
+ LogType? logTypeFilter = null,
145
+ bool includeStackTrace = false,
146
+ int lastMinutes = 0)
147
+ {
148
+ if (_isDisposed.Value)
149
+ {
150
+ _logger.LogWarning("{method} called but already disposed, ignored.",
151
+ nameof(Query));
152
+ return Array.Empty<LogEntry>();
153
+ }
154
+ lock (_fileMutex)
155
+ {
156
+ return QueryInternal(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
157
+ }
158
+ }
159
+
160
+ protected override LogEntry[] QueryInternal(
161
+ int maxEntries = 100,
162
+ LogType? logTypeFilter = null,
163
+ bool includeStackTrace = false,
164
+ int lastMinutes = 0)
165
+ {
166
+ var result = new List<LogEntry>();
167
+ var cutoffTime = lastMinutes > 0
168
+ ? System.DateTime.Now.AddMinutes(-lastMinutes)
169
+ : System.DateTime.MinValue;
170
+
171
+ // 1. Get from buffer (Newest are at the end of buffer)
172
+ for (int i = _logEntriesBufferLength - 1; i >= 0; i--)
173
+ {
174
+ var entry = _logEntriesBuffer[i];
175
+ if (logTypeFilter.HasValue && entry.LogType != logTypeFilter.Value)
176
+ continue;
177
+
178
+ if (lastMinutes > 0)
179
+ {
180
+ if (entry.Timestamp < cutoffTime)
181
+ {
182
+ return result.AsEnumerable().Reverse().ToArray();
183
+ }
184
+ }
185
+
186
+ result.Add(entry);
187
+ if (result.Count >= maxEntries)
188
+ return result.AsEnumerable().Reverse().ToArray();
189
+ }
190
+
191
+ // 2. Exit if we already have enough entries
192
+ var neededLogsCount = maxEntries - result.Count;
193
+ if (neededLogsCount <= 0)
194
+ return result.AsEnumerable().Reverse().ToArray();
195
+
196
+ result.Reverse();
197
+
198
+ // 3. Get from file
199
+ var fileEntries = base.QueryInternal(neededLogsCount, logTypeFilter, includeStackTrace, lastMinutes);
200
+ result.AddRange(fileEntries);
201
+
202
+ return result.ToArray();
203
+ }
204
+
205
+ // No finalizer: Dispose is plain managed cleanup; running arbitrary code on
206
+ // the finalizer thread during domain unload is never safe.
207
+ }
208
+ }
@@ -542,6 +542,8 @@ namespace com.AtelierAI.Unity.Copilot
542
542
  GC.SuppressFinalize(this);
543
543
  }
544
544
 
545
- ~FileLogStorage() => Dispose();
545
+ // No finalizer: Dispose performs file I/O / Unity static-event unsubscription,
546
+ // which must never run on the finalizer thread during domain unload
547
+ // (same crash class as uco-domain-reload-crash-20260919).
546
548
  }
547
549
  }
@@ -1,280 +1,282 @@
1
- /*
2
- ┌──────────────────────────────────────────────────────────────────┐
3
- │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
- │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
- │ Copyright (c) 2025 Ivan Murzak │
6
- │ Licensed under the Apache License, Version 2.0. │
7
- │ See the LICENSE file in the project root for more information. │
8
- └──────────────────────────────────────────────────────────────────┘
9
- */
10
-
11
- #nullable enable
12
- using System;
13
- using System.Text.RegularExpressions;
14
- using System.Threading;
15
- using System.Threading.Tasks;
16
- using com.AtelierAI.Unity.Copilot.Runtime.Utils;
17
- using com.AtelierAI.Unity.Copilot.Utils;
18
- using com.AtelierAI.Uco.Framework.Common;
19
- using com.IvanMurzak.ReflectorNet;
20
- using com.IvanMurzak.ReflectorNet.Utils;
21
- using UnityEngine;
22
-
23
- namespace com.AtelierAI.Unity.Copilot
24
- {
25
- /// <summary>
26
- /// Result of a detailed console query: the filtered entries plus the
27
- /// collector's loss accounting so "no errors" is distinguishable from
28
- /// "errors were dropped" (COCli-07).
29
- /// </summary>
30
- public class ConsoleLogsQueryResult
31
- {
32
- public LogEntry[] Entries { get; set; } = Array.Empty<LogEntry>();
33
- public int DroppedEntries { get; set; }
34
- public int TruncatedEntries { get; set; }
35
- }
36
-
37
- /// <summary>
38
- /// Collects Unity log messages and manages saving/loading them to/from a cache file.
39
- /// </summary>
40
- public class UnityLogCollector : IDisposable
41
- {
42
- private static readonly Regex RichTextRegex = new Regex(@"</?(b|i|size|color|material|quad|a)\b[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
43
-
44
- const int MaxMessageCharacters = 16 * 1024;
45
-
46
- readonly ILogStorage _logStorage;
47
- readonly ThreadSafeBool _isDisposed = new(false);
48
- // This collector's channel sink and the sink it displaced, so Dispose
49
- // can restore the previous channel owner instead of clearing it.
50
- readonly Action<PluginDiagnosticEntry> _channelSink;
51
- Action<PluginDiagnosticEntry>? _previousChannelSink;
52
- int _droppedEntries;
53
- int _truncatedEntries;
54
-
55
- public UnityLogCollector(ILogStorage logStorage)
56
- {
57
- if (!MainThread.Instance.IsMainThread)
58
- throw new Exception($"{GetType().GetTypeShortName()} constructor must be initialized on the main thread.");
59
-
60
- _logStorage = logStorage ?? throw new ArgumentNullException(nameof(logStorage));
61
-
62
- _channelSink = AppendPluginDiagnostic;
63
- _previousChannelSink = PluginDiagnostics.CurrentSink;
64
- Application.logMessageReceivedThreaded += OnLogMessageReceived;
65
- PluginDiagnostics.Install(_channelSink);
66
- }
67
-
68
- public LogClearResult Clear()
69
- {
70
- if (_isDisposed.Value)
71
- return new LogClearResult
72
- {
73
- Ok = false,
74
- Strategy = "collector-disposed",
75
- Error = "The Unity log collector is disposed."
76
- };
77
-
78
- return _logStorage.Clear();
79
- }
80
-
81
- /// <summary>
82
- /// Synchronously saves all current log entries to the cache file.
83
- /// </summary>
84
- public void Save()
85
- {
86
- if (_isDisposed.Value)
87
- return;
88
-
89
- _logStorage.Flush();
90
- }
91
-
92
- /// <summary>
93
- /// Asynchronously saves all current log entries to the cache file.
94
- /// </summary>
95
- /// <returns>A task that completes when the save operation is finished.</returns>
96
- public Task SaveAsync()
97
- {
98
- if (_isDisposed.Value)
99
- return Task.CompletedTask;
100
-
101
- return _logStorage.FlushAsync();
102
- }
103
-
104
- public Task<LogEntry[]> QueryAsync(
105
- int maxEntries = 100,
106
- LogType? logTypeFilter = null,
107
- bool includeStackTrace = false,
108
- int lastMinutes = 0)
109
- {
110
- return _logStorage.QueryAsync(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
111
- }
112
-
113
- public LogEntry[] Query(
114
- int maxEntries = 100,
115
- LogType? logTypeFilter = null,
116
- bool includeStackTrace = false,
117
- int lastMinutes = 0)
118
- {
119
- return _logStorage.Query(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
120
- }
121
-
122
- /// <summary>
123
- /// Detailed query with operation/correlation/source/time-boundary
124
- /// filters and loss accounting (COCli-07). Existing filter names and
125
- /// semantics are unchanged; the new filters compose with them.
126
- /// </summary>
127
- public ConsoleLogsQueryResult QueryDetailed(
128
- int maxEntries = 100,
129
- LogType? logTypeFilter = null,
130
- bool includeStackTrace = false,
131
- int lastMinutes = 0,
132
- string? correlationId = null,
133
- string? operationId = null,
134
- string? source = null,
135
- long? sinceUnixMs = null)
136
- {
137
- if (maxEntries < 1)
138
- throw new ArgumentOutOfRangeException(nameof(maxEntries));
139
- if (!string.IsNullOrEmpty(source)
140
- && source != LogEntry.SourceProduct
141
- && source != LogEntry.SourceBridge
142
- && source != LogEntry.SourceTool
143
- && source != LogEntry.SourceUnity)
144
- {
145
- throw new ArgumentException(
146
- $"source must be one of: {LogEntry.SourceProduct}, {LogEntry.SourceBridge}, {LogEntry.SourceTool}, {LogEntry.SourceUnity}.");
147
- }
148
-
149
- // Oversubscribe the storage query so the id/source filters below
150
- // can still fill maxEntries from a wider recent window.
151
- var fetchLimit = string.IsNullOrEmpty(correlationId) && string.IsNullOrEmpty(operationId)
152
- ? maxEntries
153
- : Math.Min(2_000, maxEntries * 10);
154
- var candidates = _logStorage.Query(
155
- fetchLimit, logTypeFilter, includeStackTrace, lastMinutes);
156
-
157
- var results = new System.Collections.Generic.List<LogEntry>(candidates.Length);
158
- var boundary = sinceUnixMs.HasValue
159
- ? DateTimeOffset.FromUnixTimeMilliseconds(sinceUnixMs.Value).LocalDateTime
160
- : (DateTime?)null;
161
- // Storage returns newest-first; keep the newest maxEntries after
162
- // filtering, then present them oldest-first for readability.
163
- for (var i = 0; i < candidates.Length && results.Count < maxEntries; i++)
164
- {
165
- var entry = candidates[i];
166
- if (!string.IsNullOrEmpty(correlationId)
167
- && !string.Equals(entry.CorrelationId, correlationId, StringComparison.Ordinal))
168
- continue;
169
- if (!string.IsNullOrEmpty(operationId)
170
- && !string.Equals(entry.OperationId, operationId, StringComparison.Ordinal))
171
- continue;
172
- if (!string.IsNullOrEmpty(source)
173
- && !string.Equals(entry.Source, source, StringComparison.Ordinal))
174
- continue;
175
- if (boundary.HasValue && entry.Timestamp < boundary.Value)
176
- continue;
177
- results.Add(entry);
178
- }
179
- results.Reverse();
180
-
181
- return new ConsoleLogsQueryResult
182
- {
183
- Entries = results.ToArray(),
184
- DroppedEntries = Volatile.Read(ref _droppedEntries) + PluginDiagnostics.DroppedEntries,
185
- TruncatedEntries = Volatile.Read(ref _truncatedEntries),
186
- };
187
- }
188
-
189
- /// <summary>Sink for the plugin/bridge/tool diagnostics channel.</summary>
190
- void AppendPluginDiagnostic(PluginDiagnosticEntry entry)
191
- {
192
- if (_isDisposed.Value || entry == null) return;
193
- try
194
- {
195
- _logStorage.Append(new LogEntry(
196
- logType: entry.LogType,
197
- message: BoundMessage(entry.Message, out var truncated),
198
- source: entry.Source,
199
- correlationId: entry.CorrelationId,
200
- operationId: entry.OperationId,
201
- timestamp: DateTime.Now,
202
- stackTrace: entry.StackTrace));
203
- if (truncated)
204
- Interlocked.Increment(ref _truncatedEntries);
205
- }
206
- catch
207
- {
208
- Interlocked.Increment(ref _droppedEntries);
209
- }
210
- }
211
-
212
- void OnLogMessageReceived(string message, string stackTrace, LogType type)
213
- {
214
- try
215
- {
216
- // Strip rich text tags
217
- var cleanMessage = BoundMessage(RichTextRegex.Replace(message, string.Empty), out var truncated);
218
-
219
- // COCli-07 attribution: entries produced on the main thread
220
- // inside an owned execution window carry that call's ids and
221
- // classify as product; everything Unity forwards otherwise
222
- // stays source=unity with empty correlation fields.
223
- string? correlationId = null;
224
- string? operationId = null;
225
- var source = LogEntry.SourceUnity;
226
- if (MainThread.Instance.IsMainThread && LogCallScope.Current is { } frame)
227
- {
228
- correlationId = frame.CorrelationId;
229
- operationId = frame.OperationId;
230
- source = LogEntry.SourceProduct;
231
- }
232
-
233
- _logStorage.Append(new LogEntry(
234
- logType: type,
235
- message: cleanMessage,
236
- source: source,
237
- correlationId: correlationId,
238
- operationId: operationId,
239
- timestamp: DateTime.Now,
240
- stackTrace: string.IsNullOrEmpty(stackTrace) ? null : stackTrace));
241
- if (truncated)
242
- Interlocked.Increment(ref _truncatedEntries);
243
- }
244
- catch
245
- {
246
- Interlocked.Increment(ref _droppedEntries);
247
- }
248
- }
249
-
250
- string BoundMessage(string message, out bool truncated)
251
- {
252
- if (message.Length <= MaxMessageCharacters)
253
- {
254
- truncated = false;
255
- return message;
256
- }
257
- truncated = true;
258
- return message.Substring(0, MaxMessageCharacters);
259
- }
260
-
261
- public void Dispose()
262
- {
263
- if (!_isDisposed.TrySetTrue())
264
- return; // already disposed
265
-
266
- Application.logMessageReceivedThreaded -= OnLogMessageReceived;
267
- // Hand the channel back to whatever sink was installed before this
268
- // collector (e.g. the plugin's own collector) instead of clearing
269
- // it: a temporary collector must not disable console separation for
270
- // the rest of the session. A newer install is left untouched.
271
- PluginDiagnostics.RestoreIfCurrent(_channelSink, _previousChannelSink);
272
- _previousChannelSink = null;
273
- _logStorage.Dispose();
274
-
275
- GC.SuppressFinalize(this);
276
- }
277
-
278
- ~UnityLogCollector() => Dispose();
279
- }
280
- }
1
+ /*
2
+ ┌──────────────────────────────────────────────────────────────────┐
3
+ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
4
+ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
5
+ │ Copyright (c) 2025 Ivan Murzak │
6
+ │ Licensed under the Apache License, Version 2.0. │
7
+ │ See the LICENSE file in the project root for more information. │
8
+ └──────────────────────────────────────────────────────────────────┘
9
+ */
10
+
11
+ #nullable enable
12
+ using System;
13
+ using System.Text.RegularExpressions;
14
+ using System.Threading;
15
+ using System.Threading.Tasks;
16
+ using com.AtelierAI.Unity.Copilot.Runtime.Utils;
17
+ using com.AtelierAI.Unity.Copilot.Utils;
18
+ using com.AtelierAI.Uco.Framework.Common;
19
+ using com.IvanMurzak.ReflectorNet;
20
+ using com.IvanMurzak.ReflectorNet.Utils;
21
+ using UnityEngine;
22
+
23
+ namespace com.AtelierAI.Unity.Copilot
24
+ {
25
+ /// <summary>
26
+ /// Result of a detailed console query: the filtered entries plus the
27
+ /// collector's loss accounting so "no errors" is distinguishable from
28
+ /// "errors were dropped" (COCli-07).
29
+ /// </summary>
30
+ public class ConsoleLogsQueryResult
31
+ {
32
+ public LogEntry[] Entries { get; set; } = Array.Empty<LogEntry>();
33
+ public int DroppedEntries { get; set; }
34
+ public int TruncatedEntries { get; set; }
35
+ }
36
+
37
+ /// <summary>
38
+ /// Collects Unity log messages and manages saving/loading them to/from a cache file.
39
+ /// </summary>
40
+ public class UnityLogCollector : IDisposable
41
+ {
42
+ private static readonly Regex RichTextRegex = new Regex(@"</?(b|i|size|color|material|quad|a)\b[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
43
+
44
+ const int MaxMessageCharacters = 16 * 1024;
45
+
46
+ readonly ILogStorage _logStorage;
47
+ readonly ThreadSafeBool _isDisposed = new(false);
48
+ // This collector's channel sink and the sink it displaced, so Dispose
49
+ // can restore the previous channel owner instead of clearing it.
50
+ readonly Action<PluginDiagnosticEntry> _channelSink;
51
+ Action<PluginDiagnosticEntry>? _previousChannelSink;
52
+ int _droppedEntries;
53
+ int _truncatedEntries;
54
+
55
+ public UnityLogCollector(ILogStorage logStorage)
56
+ {
57
+ if (!MainThread.Instance.IsMainThread)
58
+ throw new Exception($"{GetType().GetTypeShortName()} constructor must be initialized on the main thread.");
59
+
60
+ _logStorage = logStorage ?? throw new ArgumentNullException(nameof(logStorage));
61
+
62
+ _channelSink = AppendPluginDiagnostic;
63
+ _previousChannelSink = PluginDiagnostics.CurrentSink;
64
+ Application.logMessageReceivedThreaded += OnLogMessageReceived;
65
+ PluginDiagnostics.Install(_channelSink);
66
+ }
67
+
68
+ public LogClearResult Clear()
69
+ {
70
+ if (_isDisposed.Value)
71
+ return new LogClearResult
72
+ {
73
+ Ok = false,
74
+ Strategy = "collector-disposed",
75
+ Error = "The Unity log collector is disposed."
76
+ };
77
+
78
+ return _logStorage.Clear();
79
+ }
80
+
81
+ /// <summary>
82
+ /// Synchronously saves all current log entries to the cache file.
83
+ /// </summary>
84
+ public void Save()
85
+ {
86
+ if (_isDisposed.Value)
87
+ return;
88
+
89
+ _logStorage.Flush();
90
+ }
91
+
92
+ /// <summary>
93
+ /// Asynchronously saves all current log entries to the cache file.
94
+ /// </summary>
95
+ /// <returns>A task that completes when the save operation is finished.</returns>
96
+ public Task SaveAsync()
97
+ {
98
+ if (_isDisposed.Value)
99
+ return Task.CompletedTask;
100
+
101
+ return _logStorage.FlushAsync();
102
+ }
103
+
104
+ public Task<LogEntry[]> QueryAsync(
105
+ int maxEntries = 100,
106
+ LogType? logTypeFilter = null,
107
+ bool includeStackTrace = false,
108
+ int lastMinutes = 0)
109
+ {
110
+ return _logStorage.QueryAsync(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
111
+ }
112
+
113
+ public LogEntry[] Query(
114
+ int maxEntries = 100,
115
+ LogType? logTypeFilter = null,
116
+ bool includeStackTrace = false,
117
+ int lastMinutes = 0)
118
+ {
119
+ return _logStorage.Query(maxEntries, logTypeFilter, includeStackTrace, lastMinutes);
120
+ }
121
+
122
+ /// <summary>
123
+ /// Detailed query with operation/correlation/source/time-boundary
124
+ /// filters and loss accounting (COCli-07). Existing filter names and
125
+ /// semantics are unchanged; the new filters compose with them.
126
+ /// </summary>
127
+ public ConsoleLogsQueryResult QueryDetailed(
128
+ int maxEntries = 100,
129
+ LogType? logTypeFilter = null,
130
+ bool includeStackTrace = false,
131
+ int lastMinutes = 0,
132
+ string? correlationId = null,
133
+ string? operationId = null,
134
+ string? source = null,
135
+ long? sinceUnixMs = null)
136
+ {
137
+ if (maxEntries < 1)
138
+ throw new ArgumentOutOfRangeException(nameof(maxEntries));
139
+ if (!string.IsNullOrEmpty(source)
140
+ && source != LogEntry.SourceProduct
141
+ && source != LogEntry.SourceBridge
142
+ && source != LogEntry.SourceTool
143
+ && source != LogEntry.SourceUnity)
144
+ {
145
+ throw new ArgumentException(
146
+ $"source must be one of: {LogEntry.SourceProduct}, {LogEntry.SourceBridge}, {LogEntry.SourceTool}, {LogEntry.SourceUnity}.");
147
+ }
148
+
149
+ // Oversubscribe the storage query so the id/source filters below
150
+ // can still fill maxEntries from a wider recent window.
151
+ var fetchLimit = string.IsNullOrEmpty(correlationId) && string.IsNullOrEmpty(operationId)
152
+ ? maxEntries
153
+ : Math.Min(2_000, maxEntries * 10);
154
+ var candidates = _logStorage.Query(
155
+ fetchLimit, logTypeFilter, includeStackTrace, lastMinutes);
156
+
157
+ var results = new System.Collections.Generic.List<LogEntry>(candidates.Length);
158
+ var boundary = sinceUnixMs.HasValue
159
+ ? DateTimeOffset.FromUnixTimeMilliseconds(sinceUnixMs.Value).LocalDateTime
160
+ : (DateTime?)null;
161
+ // Storage returns newest-first; keep the newest maxEntries after
162
+ // filtering, then present them oldest-first for readability.
163
+ for (var i = 0; i < candidates.Length && results.Count < maxEntries; i++)
164
+ {
165
+ var entry = candidates[i];
166
+ if (!string.IsNullOrEmpty(correlationId)
167
+ && !string.Equals(entry.CorrelationId, correlationId, StringComparison.Ordinal))
168
+ continue;
169
+ if (!string.IsNullOrEmpty(operationId)
170
+ && !string.Equals(entry.OperationId, operationId, StringComparison.Ordinal))
171
+ continue;
172
+ if (!string.IsNullOrEmpty(source)
173
+ && !string.Equals(entry.Source, source, StringComparison.Ordinal))
174
+ continue;
175
+ if (boundary.HasValue && entry.Timestamp < boundary.Value)
176
+ continue;
177
+ results.Add(entry);
178
+ }
179
+ results.Reverse();
180
+
181
+ return new ConsoleLogsQueryResult
182
+ {
183
+ Entries = results.ToArray(),
184
+ DroppedEntries = Volatile.Read(ref _droppedEntries) + PluginDiagnostics.DroppedEntries,
185
+ TruncatedEntries = Volatile.Read(ref _truncatedEntries),
186
+ };
187
+ }
188
+
189
+ /// <summary>Sink for the plugin/bridge/tool diagnostics channel.</summary>
190
+ void AppendPluginDiagnostic(PluginDiagnosticEntry entry)
191
+ {
192
+ if (_isDisposed.Value || entry == null) return;
193
+ try
194
+ {
195
+ _logStorage.Append(new LogEntry(
196
+ logType: entry.LogType,
197
+ message: BoundMessage(entry.Message, out var truncated),
198
+ source: entry.Source,
199
+ correlationId: entry.CorrelationId,
200
+ operationId: entry.OperationId,
201
+ timestamp: DateTime.Now,
202
+ stackTrace: entry.StackTrace));
203
+ if (truncated)
204
+ Interlocked.Increment(ref _truncatedEntries);
205
+ }
206
+ catch
207
+ {
208
+ Interlocked.Increment(ref _droppedEntries);
209
+ }
210
+ }
211
+
212
+ void OnLogMessageReceived(string message, string stackTrace, LogType type)
213
+ {
214
+ try
215
+ {
216
+ // Strip rich text tags
217
+ var cleanMessage = BoundMessage(RichTextRegex.Replace(message, string.Empty), out var truncated);
218
+
219
+ // COCli-07 attribution: entries produced on the main thread
220
+ // inside an owned execution window carry that call's ids and
221
+ // classify as product; everything Unity forwards otherwise
222
+ // stays source=unity with empty correlation fields.
223
+ string? correlationId = null;
224
+ string? operationId = null;
225
+ var source = LogEntry.SourceUnity;
226
+ if (MainThread.Instance.IsMainThread && LogCallScope.Current is { } frame)
227
+ {
228
+ correlationId = frame.CorrelationId;
229
+ operationId = frame.OperationId;
230
+ source = LogEntry.SourceProduct;
231
+ }
232
+
233
+ _logStorage.Append(new LogEntry(
234
+ logType: type,
235
+ message: cleanMessage,
236
+ source: source,
237
+ correlationId: correlationId,
238
+ operationId: operationId,
239
+ timestamp: DateTime.Now,
240
+ stackTrace: string.IsNullOrEmpty(stackTrace) ? null : stackTrace));
241
+ if (truncated)
242
+ Interlocked.Increment(ref _truncatedEntries);
243
+ }
244
+ catch
245
+ {
246
+ Interlocked.Increment(ref _droppedEntries);
247
+ }
248
+ }
249
+
250
+ string BoundMessage(string message, out bool truncated)
251
+ {
252
+ if (message.Length <= MaxMessageCharacters)
253
+ {
254
+ truncated = false;
255
+ return message;
256
+ }
257
+ truncated = true;
258
+ return message.Substring(0, MaxMessageCharacters);
259
+ }
260
+
261
+ public void Dispose()
262
+ {
263
+ if (!_isDisposed.TrySetTrue())
264
+ return; // already disposed
265
+
266
+ Application.logMessageReceivedThreaded -= OnLogMessageReceived;
267
+ // Hand the channel back to whatever sink was installed before this
268
+ // collector (e.g. the plugin's own collector) instead of clearing
269
+ // it: a temporary collector must not disable console separation for
270
+ // the rest of the session. A newer install is left untouched.
271
+ PluginDiagnostics.RestoreIfCurrent(_channelSink, _previousChannelSink);
272
+ _previousChannelSink = null;
273
+ _logStorage.Dispose();
274
+
275
+ GC.SuppressFinalize(this);
276
+ }
277
+
278
+ // No finalizer: Dispose performs file I/O / Unity static-event unsubscription,
279
+ // which must never run on the finalizer thread during domain unload
280
+ // (same crash class as uco-domain-reload-crash-20260919).
281
+ }
282
+ }
@@ -26,7 +26,7 @@ namespace com.AtelierAI.Unity.Copilot
26
26
 
27
27
  public partial class UnityCopilotPlugin : IDisposable
28
28
  {
29
- public const string Version = "1.0.7";
29
+ public const string Version = "1.0.8";
30
30
 
31
31
  private static int _singletonCount = 0;
32
32
  public static bool HasAnyInstance => _singletonCount > 0;
@@ -15,7 +15,7 @@
15
15
  "Unity Skills",
16
16
  "uco"
17
17
  ],
18
- "version": "1.0.7",
18
+ "version": "1.0.8",
19
19
  "unity": "2022.3",
20
20
  "description": "uco — Unity Copilot: AI Skills, Tools, and CLI for the Unity Engine, driven over plain REST + WebSocket. Any C# method may be turned into a tool by a single line. Derived from IvanMurzak/Unity-MCP (Apache-2.0).",
21
21
  "documentationUrl": "https://github.com/DumoeDss/uco-plugin/tree/main/uco-unity-project/Packages/com.atelierai.unity.copilot#readme",