@mflrevan/ucp 0.6.0 → 0.6.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.6.0` of the Unity Control Protocol CLI.
3
+ Version `0.6.1` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.6.0";
29
+ private const string ProtocolVersion = "0.6.1";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -36,6 +36,13 @@ namespace UCP.Bridge
36
36
  private static string s_token;
37
37
  private static bool s_running;
38
38
 
39
+ // Handshake payload, captured once on the main thread at startup. `Application.dataPath`
40
+ // and friends are main-thread-only, which is the sole reason the handshake used to be
41
+ // queued behind `EditorApplication.update` -- see the socket-thread fast path in Dispatch.
42
+ private static string s_unityVersion = string.Empty;
43
+ private static string s_projectName = string.Empty;
44
+ private static string s_projectPath = string.Empty;
45
+
39
46
  // Main-thread action queue
40
47
  private static readonly ConcurrentQueue<Action> s_mainThreadQueue = new();
41
48
 
@@ -71,6 +78,7 @@ namespace UCP.Bridge
71
78
 
72
79
  try
73
80
  {
81
+ CaptureEditorIdentity();
74
82
  RegisterHandlers();
75
83
  LogsController.SeedHistoryFromConsole();
76
84
 
@@ -88,18 +96,31 @@ namespace UCP.Bridge
88
96
  }
89
97
  }
90
98
 
99
+ /// <summary>
100
+ /// Read the editor/project identity once, on the main thread, so the handshake handler is
101
+ /// pure and can run anywhere.
102
+ /// </summary>
103
+ private static void CaptureEditorIdentity()
104
+ {
105
+ s_unityVersion = Application.unityVersion;
106
+ s_projectName = Application.productName;
107
+ s_projectPath = Path.GetDirectoryName(Application.dataPath);
108
+ }
109
+
91
110
  private static void RegisterHandlers()
92
111
  {
93
112
  // Handshake
94
113
  s_router.Register("handshake", (paramsJson) =>
95
114
  {
115
+ // Touches no Unity API -- see CaptureEditorIdentity. Keep it that way: the
116
+ // socket-thread fast path in Dispatch depends on it.
96
117
  return new
97
118
  {
98
119
  serverVersion = ProtocolVersion,
99
120
  protocolVersion = ProtocolVersion,
100
- unityVersion = Application.unityVersion,
101
- projectName = Application.productName,
102
- projectPath = Path.GetDirectoryName(Application.dataPath)
121
+ unityVersion = s_unityVersion,
122
+ projectName = s_projectName,
123
+ projectPath = s_projectPath
103
124
  };
104
125
  });
105
126
 
@@ -393,12 +414,22 @@ namespace UCP.Bridge
393
414
  lock (s_clientLock) { s_logSubscribers.Remove(ws); }
394
415
  }
395
416
 
396
- // Dispatch on main thread
397
417
  var capturedId = id;
398
418
  var capturedMethod = method;
399
419
  var capturedParams = paramsJson;
400
420
  var capturedWs = ws;
401
421
 
422
+ // Answer the handshake straight off the socket thread. Everything it returns is
423
+ // cached, so queueing it only bought a wait for the next `EditorApplication.update`
424
+ // tick -- roughly 90 ms on an idle editor, paid by *every* CLI command before its
425
+ // real request could even be dispatched.
426
+ if (capturedMethod == "handshake")
427
+ {
428
+ SendResponse(capturedWs, s_router.Dispatch(capturedMethod, capturedId, capturedParams));
429
+ return;
430
+ }
431
+
432
+ // Dispatch on main thread
402
433
  s_mainThreadQueue.Enqueue(() =>
403
434
  {
404
435
  var response = s_router.Dispatch(capturedMethod, capturedId, capturedParams);
@@ -9,19 +9,86 @@ namespace UCP.Bridge
9
9
  {
10
10
  public static class CompilationController
11
11
  {
12
+ // SessionState survives domain reloads within one editor session. That is exactly the
13
+ // window we need: a compile that SUCCEEDS reloads the domain, a compile that FAILS keeps
14
+ // the old domain (no reload), and in both cases the captured diagnostics must still be
15
+ // readable by the CLI after the editor settles. CompileDiagnosticsTracker writes here;
16
+ // HandleDiagnostics reads it back.
17
+ internal const string DiagStateKey = "ucp.compile.diagnostics.state";
18
+ internal const string DiagRequestKey = "ucp.compile.diagnostics.requestId";
19
+ private const int MaxDiagnosticMessages = 200;
20
+
12
21
  public static void Register(CommandRouter router)
13
22
  {
14
23
  router.Register("compile", HandleCompile);
24
+ router.Register("compile/diagnostics", HandleDiagnostics);
15
25
  router.Register("refresh-assets", HandleRefresh);
16
26
  router.Register("script/doctor", HandleScriptDoctor);
17
27
  }
18
28
 
19
29
  private static object HandleCompile(string paramsJson)
20
30
  {
31
+ // Stamp a fresh request id and reset the diagnostics buffer so the CLI can tell THIS
32
+ // compile's result apart from a stale one. CompileDiagnosticsTracker fills in the
33
+ // per-assembly CompilerMessages as compilation progresses and finishes.
34
+ var requestId = SessionState.GetInt(DiagRequestKey, 0) + 1;
35
+ SessionState.SetInt(DiagRequestKey, requestId);
36
+ SessionState.SetString(DiagStateKey, MiniJson.Serialize(new Dictionary<string, object>
37
+ {
38
+ ["status"] = "requested",
39
+ ["requestId"] = requestId,
40
+ ["errorCount"] = 0,
41
+ ["warningCount"] = 0,
42
+ ["messages"] = new List<object>()
43
+ }));
44
+
21
45
  AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
22
46
  CompilationPipeline.RequestScriptCompilation();
23
47
  TrySyncSolution();
24
- return new { status = "ok", message = "Asset database refreshed and compilation requested" };
48
+ return new Dictionary<string, object>
49
+ {
50
+ ["status"] = "ok",
51
+ ["message"] = "Asset database refreshed and compilation requested",
52
+ ["requestId"] = requestId
53
+ };
54
+ }
55
+
56
+ private static object HandleDiagnostics(string paramsJson)
57
+ {
58
+ var raw = SessionState.GetString(DiagStateKey, string.Empty);
59
+ if (string.IsNullOrEmpty(raw)
60
+ || !(MiniJson.Deserialize(raw) is Dictionary<string, object> state))
61
+ {
62
+ return new Dictionary<string, object>
63
+ {
64
+ ["status"] = "idle",
65
+ ["requestId"] = SessionState.GetInt(DiagRequestKey, 0),
66
+ ["errorCount"] = 0,
67
+ ["warningCount"] = 0,
68
+ ["messages"] = new List<object>(),
69
+ ["compiling"] = EditorApplication.isCompiling
70
+ };
71
+ }
72
+
73
+ // Surface live compile state too: a "completed" buffer while isCompiling is true means
74
+ // another compilation has already started after the one the CLI asked about.
75
+ state["compiling"] = EditorApplication.isCompiling;
76
+ return state;
77
+ }
78
+
79
+ internal static void WriteDiagnosticsState(string status, int errorCount, int warningCount, List<object> messages)
80
+ {
81
+ var truncated = messages.Count > MaxDiagnosticMessages;
82
+ var trimmed = truncated ? messages.GetRange(0, MaxDiagnosticMessages) : messages;
83
+ SessionState.SetString(DiagStateKey, MiniJson.Serialize(new Dictionary<string, object>
84
+ {
85
+ ["status"] = status,
86
+ ["requestId"] = SessionState.GetInt(DiagRequestKey, 0),
87
+ ["errorCount"] = errorCount,
88
+ ["warningCount"] = warningCount,
89
+ ["truncated"] = truncated,
90
+ ["messages"] = new List<object>(trimmed)
91
+ }));
25
92
  }
26
93
 
27
94
  private static object HandleRefresh(string paramsJson)
@@ -110,4 +177,73 @@ namespace UCP.Bridge
110
177
  }
111
178
  }
112
179
  }
180
+
181
+ /// <summary>
182
+ /// Captures per-assembly compiler messages so `compile` can report whether the build actually
183
+ /// succeeded instead of always claiming success. Subscribes once per domain load; accumulates
184
+ /// messages for the in-flight compilation in static fields (safe because a domain reload only
185
+ /// happens AFTER compilationFinished) and flushes them to SessionState, which persists across
186
+ /// the reload for the CLI to read once the editor settles.
187
+ /// </summary>
188
+ [InitializeOnLoad]
189
+ internal static class CompileDiagnosticsTracker
190
+ {
191
+ private static readonly List<object> s_messages = new List<object>();
192
+ private static int s_errorCount;
193
+ private static int s_warningCount;
194
+
195
+ static CompileDiagnosticsTracker()
196
+ {
197
+ CompilationPipeline.compilationStarted += OnCompilationStarted;
198
+ CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished;
199
+ CompilationPipeline.compilationFinished += OnCompilationFinished;
200
+ }
201
+
202
+ private static void OnCompilationStarted(object context)
203
+ {
204
+ s_messages.Clear();
205
+ s_errorCount = 0;
206
+ s_warningCount = 0;
207
+ CompilationController.WriteDiagnosticsState("compiling", 0, 0, s_messages);
208
+ }
209
+
210
+ private static void OnAssemblyCompilationFinished(string assemblyPath, CompilerMessage[] messages)
211
+ {
212
+ if (messages == null) return;
213
+ var assembly = Path.GetFileNameWithoutExtension(assemblyPath);
214
+ foreach (var message in messages)
215
+ {
216
+ string type;
217
+ switch (message.type)
218
+ {
219
+ case CompilerMessageType.Error:
220
+ type = "error";
221
+ s_errorCount++;
222
+ break;
223
+ case CompilerMessageType.Warning:
224
+ type = "warning";
225
+ s_warningCount++;
226
+ break;
227
+ default:
228
+ type = "info";
229
+ break;
230
+ }
231
+
232
+ s_messages.Add(new Dictionary<string, object>
233
+ {
234
+ ["assembly"] = assembly,
235
+ ["type"] = type,
236
+ ["message"] = message.message ?? string.Empty,
237
+ ["file"] = message.file ?? string.Empty,
238
+ ["line"] = message.line,
239
+ ["column"] = message.column
240
+ });
241
+ }
242
+ }
243
+
244
+ private static void OnCompilationFinished(object context)
245
+ {
246
+ CompilationController.WriteDiagnosticsState("completed", s_errorCount, s_warningCount, s_messages);
247
+ }
248
+ }
113
249
  }
@@ -15,6 +15,11 @@ namespace UCP.Bridge
15
15
  {
16
16
  private const int DefaultFrameListLimit = 20;
17
17
  private const int DefaultSummaryFrameWindow = 120;
18
+
19
+ // Summary aggregation walks every raw frame view on the main thread, so an unbounded
20
+ // range (e.g. --first-frame 0 on a long session) freezes the editor for seconds. Clamp it
21
+ // and say so in `warnings` rather than stalling.
22
+ private const int MaxSummaryFrameSpan = 600;
18
23
  private const int DefaultJsonExportFrameWindow = 120;
19
24
  private const int MaxThreadProbeCount = 128;
20
25
  private const long MinimumProfilerMemoryBytes = 16L * 1024L * 1024L;
@@ -296,6 +301,7 @@ namespace UCP.Bridge
296
301
  var limit = Math.Max(1, GetInt(parameters, "limit", 50));
297
302
  var sort = GetString(parameters, "sort") ?? "total-time";
298
303
  var maxDepth = GetNullableInt(parameters, "maxDepth");
304
+ var fields = GetFieldSet(parameters);
299
305
 
300
306
  using (var view = GetHierarchyFrameDataView(frameIndex, threadIndex))
301
307
  {
@@ -305,7 +311,8 @@ namespace UCP.Bridge
305
311
  var items = CollectHierarchyItems(view, maxDepth);
306
312
  items = SortHierarchyItems(items, sort);
307
313
 
308
- var truncated = items.Count > limit;
314
+ var totalCount = items.Count;
315
+ var truncated = totalCount > limit;
309
316
  if (truncated)
310
317
  items = items.Take(limit).ToList();
311
318
 
@@ -315,8 +322,11 @@ namespace UCP.Bridge
315
322
  ["thread"] = threadIndex,
316
323
  ["sort"] = sort,
317
324
  ["count"] = items.Count,
325
+ // How many rows existed before truncation. Without it a caller cannot tell
326
+ // "50 of 52" from "50 of 50,000", so it cannot decide whether to look further.
327
+ ["totalCount"] = totalCount,
318
328
  ["truncated"] = truncated,
319
- ["items"] = items.Select(item => (object)item.ToDictionary()).ToList(),
329
+ ["items"] = items.Select(item => (object)ProjectFields(item.ToDictionary(), fields)).ToList(),
320
330
  ["warnings"] = new List<object>()
321
331
  };
322
332
  }
@@ -330,6 +340,7 @@ namespace UCP.Bridge
330
340
  var limit = Math.Max(1, GetInt(parameters, "limit", 200));
331
341
  var maxDepth = GetNullableInt(parameters, "maxDepth");
332
342
  var includeMetadata = GetBool(parameters, "includeMetadata", false);
343
+ var fields = GetFieldSet(parameters);
333
344
 
334
345
  using (var view = GetRawFrameDataView(frameIndex, threadIndex))
335
346
  {
@@ -337,7 +348,8 @@ namespace UCP.Bridge
337
348
  throw new ArgumentException($"Raw profiler data is unavailable for frame {frameIndex}, thread {threadIndex}");
338
349
 
339
350
  var samples = CollectTimelineSamples(view, maxDepth, includeMetadata);
340
- var truncated = samples.Count > limit;
351
+ var totalCount = samples.Count;
352
+ var truncated = totalCount > limit;
341
353
  if (truncated)
342
354
  samples = samples.Take(limit).ToList();
343
355
 
@@ -346,8 +358,9 @@ namespace UCP.Bridge
346
358
  ["frame"] = frameIndex,
347
359
  ["thread"] = threadIndex,
348
360
  ["count"] = samples.Count,
361
+ ["totalCount"] = totalCount,
349
362
  ["truncated"] = truncated,
350
- ["samples"] = samples.Cast<object>().ToList(),
363
+ ["samples"] = samples.Select(sample => (object)ProjectFields(sample, fields)).ToList(),
351
364
  ["warnings"] = new List<object>()
352
365
  };
353
366
  }
@@ -427,10 +440,23 @@ namespace UCP.Bridge
427
440
  if (firstFrame > lastFrame)
428
441
  throw new ArgumentException("Requested frame range is empty");
429
442
 
443
+ var warnings = new List<object>();
444
+ var span = lastFrame - firstFrame + 1;
445
+ if (span > MaxSummaryFrameSpan)
446
+ {
447
+ // Aggregation runs on the editor's main thread; a huge span stalls the whole
448
+ // editor. Keep the most recent frames, which is what callers almost always want.
449
+ firstFrame = lastFrame - MaxSummaryFrameSpan + 1;
450
+ warnings.Add(
451
+ $"Requested {span} frames; aggregated the most recent {MaxSummaryFrameSpan} " +
452
+ $"(frames {firstFrame}-{lastFrame}) to avoid stalling the editor. " +
453
+ "Narrow the range with --first-frame/--last-frame for older windows.");
454
+ }
455
+
430
456
  return new Dictionary<string, object>
431
457
  {
432
458
  ["summary"] = BuildSummaryData(limit, threadIndex, firstFrame, lastFrame),
433
- ["warnings"] = new List<object>()
459
+ ["warnings"] = warnings
434
460
  };
435
461
  }
436
462
 
@@ -1365,6 +1391,55 @@ namespace UCP.Bridge
1365
1391
  StringComparison.OrdinalIgnoreCase);
1366
1392
  }
1367
1393
 
1394
+ /// <summary>
1395
+ /// Optional caller-supplied field allow-list (`fields: ["name","selfMs"]`). Profiler rows
1396
+ /// are wide and agents pay per token for columns they never read, so let them ask for the
1397
+ /// two or three they actually want. Null means "every field".
1398
+ /// </summary>
1399
+ private static HashSet<string> GetFieldSet(Dictionary<string, object> parameters)
1400
+ {
1401
+ if (parameters == null || !parameters.TryGetValue("fields", out var raw) || raw == null)
1402
+ return null;
1403
+
1404
+ var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
1405
+ if (raw is List<object> list)
1406
+ {
1407
+ foreach (var entry in list)
1408
+ {
1409
+ var name = entry?.ToString();
1410
+ if (!string.IsNullOrWhiteSpace(name)) set.Add(name.Trim());
1411
+ }
1412
+ }
1413
+ else
1414
+ {
1415
+ foreach (var name in raw.ToString().Split(','))
1416
+ {
1417
+ if (!string.IsNullOrWhiteSpace(name)) set.Add(name.Trim());
1418
+ }
1419
+ }
1420
+
1421
+ return set.Count == 0 ? null : set;
1422
+ }
1423
+
1424
+ /// Narrow one row to the requested fields. Unknown names are ignored rather than erroring,
1425
+ /// so a caller can ask for a superset across profiler surfaces without branching.
1426
+ private static Dictionary<string, object> ProjectFields(
1427
+ Dictionary<string, object> row,
1428
+ HashSet<string> fields)
1429
+ {
1430
+ if (fields == null || row == null) return row;
1431
+
1432
+ var projected = new Dictionary<string, object>();
1433
+ foreach (var pair in row)
1434
+ {
1435
+ if (fields.Contains(pair.Key)) projected[pair.Key] = pair.Value;
1436
+ }
1437
+
1438
+ // Never hand back an empty row: a caller that misspelled every field would otherwise
1439
+ // get a silent wall of `{}` instead of a usable result.
1440
+ return projected.Count == 0 ? row : projected;
1441
+ }
1442
+
1368
1443
  private static Dictionary<string, object> BuildSummaryData(
1369
1444
  int limit,
1370
1445
  int threadIndex,
@@ -168,19 +168,41 @@ namespace UCP.Bridge
168
168
  sceneView.Repaint();
169
169
  SceneView.RepaintAll();
170
170
 
171
+ ResolveSceneViewCamera(sceneView, out var cameraPosition, out var cameraRotation);
172
+
171
173
  return new Dictionary<string, object>
172
174
  {
173
175
  ["status"] = "ok",
174
176
  ["instanceId"] = instanceId,
175
177
  ["name"] = target.name,
176
178
  ["pivot"] = VectorToList(sceneView.pivot),
177
- ["cameraPosition"] = VectorToList(sceneView.camera.transform.position),
178
- ["cameraRotationEuler"] = VectorToList(sceneView.camera.transform.rotation.eulerAngles),
179
+ ["cameraPosition"] = VectorToList(cameraPosition),
180
+ ["cameraRotationEuler"] = VectorToList(cameraRotation.eulerAngles),
179
181
  ["size"] = sceneView.size,
180
182
  ["axis"] = axis.HasValue ? VectorToList(axis.Value.normalized) : null
181
183
  };
182
184
  }
183
185
 
186
+ /// <summary>
187
+ /// Derive the Scene view camera pose from the authoritative view state
188
+ /// (<c>pivot</c>/<c>rotation</c>/<c>cameraDistance</c>) rather than reading
189
+ /// <c>sceneView.camera.transform</c>.
190
+ ///
191
+ /// <c>LookAtDirect</c> updates the view state immediately, but the camera transform is only
192
+ /// synced when the Scene view actually repaints. <c>Repaint()</c> merely queues that, so in
193
+ /// batch mode - and for any caller reading the response in the same frame as the focus -
194
+ /// the camera transform still holds the *previous* pose and the reported values were simply
195
+ /// wrong.
196
+ /// </summary>
197
+ private static void ResolveSceneViewCamera(
198
+ SceneView sceneView,
199
+ out Vector3 position,
200
+ out Quaternion rotation)
201
+ {
202
+ rotation = sceneView.rotation;
203
+ position = sceneView.pivot - (rotation * Vector3.forward) * sceneView.cameraDistance;
204
+ }
205
+
184
206
  private static Vector3? TryReadAxis(Dictionary<string, object> parameters)
185
207
  {
186
208
  if (parameters == null || !parameters.TryGetValue("axis", out var axisObj) || axisObj == null)
@@ -12,29 +12,35 @@ namespace UCP.Bridge
12
12
  router.Register("exec/run", HandleRun);
13
13
  }
14
14
 
15
- private static List<IUCPScript> DiscoverScripts()
15
+ /// <summary>
16
+ /// Implementing types, cached for the lifetime of the app domain. The scan itself is the
17
+ /// expensive part -- `GetTypes()` over every loaded assembly -- and its result cannot go
18
+ /// stale without a domain reload, which resets this static anyway.
19
+ /// </summary>
20
+ private static Type[] s_scriptTypes;
21
+
22
+ private static Type[] DiscoverScriptTypes()
16
23
  {
17
- var scripts = new List<IUCPScript>();
24
+ if (s_scriptTypes != null) return s_scriptTypes;
25
+
18
26
  var interfaceType = typeof(IUCPScript);
27
+ var bridgeAssemblyName = interfaceType.Assembly.GetName().Name;
28
+ var types = new List<Type>();
19
29
 
20
30
  foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
21
31
  {
32
+ // A type can only implement IUCPScript if its assembly references the one that
33
+ // declares it. Checking cheap reference metadata first avoids materialising the
34
+ // full type list of every framework and Unity assembly in the domain.
35
+ if (assembly != interfaceType.Assembly && !ReferencesAssembly(assembly, bridgeAssemblyName))
36
+ continue;
37
+
22
38
  try
23
39
  {
24
40
  foreach (var type in assembly.GetTypes())
25
41
  {
26
42
  if (interfaceType.IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
27
- {
28
- try
29
- {
30
- var instance = (IUCPScript)Activator.CreateInstance(type);
31
- scripts.Add(instance);
32
- }
33
- catch (Exception ex)
34
- {
35
- Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
36
- }
37
- }
43
+ types.Add(type);
38
44
  }
39
45
  }
40
46
  catch (System.Reflection.ReflectionTypeLoadException)
@@ -43,9 +49,71 @@ namespace UCP.Bridge
43
49
  }
44
50
  }
45
51
 
52
+ s_scriptTypes = types.ToArray();
53
+ return s_scriptTypes;
54
+ }
55
+
56
+ private static bool ReferencesAssembly(System.Reflection.Assembly assembly, string name)
57
+ {
58
+ try
59
+ {
60
+ foreach (var reference in assembly.GetReferencedAssemblies())
61
+ {
62
+ if (string.Equals(reference.Name, name, StringComparison.Ordinal)) return true;
63
+ }
64
+ }
65
+ catch
66
+ {
67
+ // Dynamic assemblies can refuse to report references; treat them as non-matching.
68
+ }
69
+
70
+ return false;
71
+ }
72
+
73
+ private static IUCPScript Instantiate(Type type)
74
+ {
75
+ try
76
+ {
77
+ return (IUCPScript)Activator.CreateInstance(type);
78
+ }
79
+ catch (Exception ex)
80
+ {
81
+ Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
82
+ return null;
83
+ }
84
+ }
85
+
86
+ private static List<IUCPScript> DiscoverScripts()
87
+ {
88
+ var scripts = new List<IUCPScript>();
89
+ foreach (var type in DiscoverScriptTypes())
90
+ {
91
+ var instance = Instantiate(type);
92
+ if (instance != null) scripts.Add(instance);
93
+ }
94
+
46
95
  return scripts;
47
96
  }
48
97
 
98
+ /// <summary>
99
+ /// Resolve one script by name, stopping at the first match.
100
+ /// `Name` is an instance member, so candidates must be constructed to be identified --
101
+ /// but running *every* script's constructor to invoke one of them is a side effect nobody
102
+ /// asked for, so stop as soon as the target is found.
103
+ /// </summary>
104
+ private static IUCPScript FindScript(string name)
105
+ {
106
+ foreach (var type in DiscoverScriptTypes())
107
+ {
108
+ var instance = Instantiate(type);
109
+ if (instance == null) continue;
110
+ if (string.Equals(instance.Name, name, StringComparison.OrdinalIgnoreCase))
111
+ return instance;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
49
117
  private static object HandleList(string paramsJson)
50
118
  {
51
119
  var scripts = DiscoverScripts();
@@ -78,16 +146,7 @@ namespace UCP.Bridge
78
146
  if (p.TryGetValue("params", out var paramsObj) && paramsObj != null)
79
147
  scriptParams = MiniJson.Serialize(paramsObj);
80
148
 
81
- var scripts = DiscoverScripts();
82
- IUCPScript target = null;
83
- foreach (var s in scripts)
84
- {
85
- if (string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase))
86
- {
87
- target = s;
88
- break;
89
- }
90
- }
149
+ var target = FindScript(name);
91
150
 
92
151
  if (target == null)
93
152
  throw new ArgumentException($"Script not found: {name}. Use exec/list to see available scripts.");