@aefree/pi-unity 0.12.1 → 0.13.0

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
@@ -7,6 +7,17 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.13.0 - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - Added advertised `get_runtime_pipeline_settings` inspection and the bounded `unity_pipeline_run_script` tool for Pipeline 0.6 ephemeral in-memory compilation/execution of one existing project C# file. Hotpatch is intentionally out of scope.
15
+
16
+ ### Fixed
17
+
18
+ - Preserve bounded Unity CLI discovery warnings/info and treat warning-bearing discovery as uncertainty rather than a confirmed absent Pipeline or launch-safe signal. Force human CLI version formatting so inherited `UNITY_FORMAT` cannot corrupt version discovery.
19
+ - Accept Pipeline 0.6 compact response envelopes and warnings without treating warnings as compiler errors. Surface ambiguous busy responses rather than blindly redispatching commands that may be blocked by a modal dialog.
20
+
10
21
  ## 0.12.1 - 2026-09-10
11
22
 
12
23
  ### Security
package/README.md CHANGED
@@ -35,11 +35,12 @@ Use these tools with an already-open exact Unity project copy that has a reachab
35
35
  - `unity_pipeline_recompile` — recompile through Pipeline with exact-copy preflight, bounded polling, and compact compiler evidence.
36
36
  - `unity_run_tests` — one intent-oriented EditMode or PlayMode workflow. It reuses compatible connected Pipeline execution or selects isolated `unity test` when the exact project copy is closed.
37
37
  - `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. It accepts `timeoutSeconds` from 1–86,400 seconds; a timeout is uncertain and does not cancel or retry Editor work.
38
- - `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
38
+ - `unity_pipeline_inspect` — dispatch supported package-owned inspection commands (including read-only `get_runtime_pipeline_settings`) and return structured evidence. Runtime settings are refused by Pipeline in Play Mode; pi-unity never exits Play Mode to read them.
39
+ - `unity_pipeline_run_script` — compile one existing project `.cs` file in Pipeline's ephemeral in-memory mode and invoke a named static entry point; supports bounded JSON arguments and compile-only `dryRun`. It deliberately does not expose hotpatch.
39
40
 
40
41
  Connected recompilation follows Unity's Script Changes While Playing policy and never preemptively sends `editor_stop`. Connected tests may exit Play Mode through advertised `editor_stop` when necessary, then verify Edit Mode before dispatch. Play Mode exit is allowed by default; `/unity-playmode-exit allow|disallow|status` controls the current session.
41
42
 
42
- A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. The sole exception is Pipeline 0.5's explicit initial-settling `Server Busy` rejection for `unity_pipeline_recompile` and `unity_run_tests`, which is known not to have dispatched a main-thread command and is retried only within the configured deadline.
43
+ A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode. Pipeline 0.6 can report busy for a modal dialog as well as startup settling, so ambiguous busy responses are surfaced and never blindly retried.
43
44
 
44
45
  ### Editor and batchmode
45
46
 
@@ -84,6 +85,7 @@ Operation-specific recovery belongs to the operational skill. `unity-debugging`
84
85
  | Existing failed-run artifacts | `unity_inspect_artifacts` |
85
86
  | Project-specific C# query or operation | `unity_pipeline_eval` |
86
87
  | Supported structured project inspection | `unity_pipeline_inspect` |
88
+ | Explicitly requested existing C# builder script | `unity_pipeline_run_script` |
87
89
  | Open the GUI explicitly | `unity_open_editor` or `/unity-open` |
88
90
 
89
91
  Pass an explicit project `path` when multiple copies may be discovered. Pipeline routing compares canonical paths so similarly named copies are not treated as interchangeable.
@@ -113,6 +115,10 @@ Another connected client is not a project lock. When Pipeline returns stable cor
113
115
 
114
116
  Use `unity_pipeline_inspect` when a purpose-built structured command fits. Use eval for bounded project-specific work that matches the user's intent. Prefer typed tools when they provide stronger lifecycle, polling, validation, or recovery semantics.
115
117
 
118
+ ### Pipeline run_script
119
+
120
+ `unity_pipeline_run_script` is arbitrary code execution, not a sandbox or read-only inspection. Use it only when the user explicitly asks to run that existing file; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations. It validates the exact project copy and advertised command twice, accepts a single existing `.cs` file inside the project, uses only `mode: ephemeral`, and never launches, saves, cancels, retries, falls back, hotpatches, or exits Play Mode. `dryRun: true` compiles without loading or executing the assembly.
121
+
116
122
  Rejected eval and inspection results are native Pi tool failures (`isError: true`) via the documented `tool_result` middleware, with structured rejection codes and bounded diagnostics retained. Pre-dispatch rejection does not execute the command; a timeout or dispatch failure can leave effects uncertain and never triggers retry or fallback.
117
123
 
118
124
  ## Launch and process safeguards
@@ -230,7 +236,9 @@ The registry-clean `package-lock.json` is committed. Optional development packag
230
236
 
231
237
  ## Unity Pipeline project side effect
232
238
 
233
- Starting `com.unity.pipeline@0.3.1-exp.1` assigns `Application.runInBackground = true`, which Unity persists as `PlayerSettings.runInBackground` in `ProjectSettings/ProjectSettings.asset`. Review that tracked change alongside `manifest.json` and `packages-lock.json` when installing Pipeline in a Unity project.
239
+ Legacy Pipeline releases (including `0.3.1-exp.1`) assigned `Application.runInBackground = true`, which Unity persisted as `PlayerSettings.runInBackground` in `ProjectSettings/ProjectSettings.asset`. Pipeline 0.6 restores the original value around server start/stop. Review tracked settings changes when installing older releases; Pipeline 0.6 reports non-automated Editor state as descriptor `info` rather than a console warning.
240
+
241
+ Pipeline 0.6 records local editor eval usage in `Library/Pipeline/eval-usage.jsonl`; raw source is not stored unless the Pipeline **Store Eval Source** setting is enabled. `UNITY_NO_CLI_INVOKED_TELEMETRY=1` opts out only of Unity CLI's per-invocation telemetry event; analytics and crash-reporting controls are separate and unchanged. Pipeline compact HTTP responses are accepted whether returned directly or inside the CLI's documented `data` wrapper; this does not assert that CLI wrapping has changed. For installed CLI/skill information, use read-only `unity skill show --list` or `unity skill show --path <path>`.
234
242
 
235
243
  ## License
236
244
 
package/index.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  type UnityParsedTestResults,
21
21
  } from "./src/unity-batchmode";
22
22
  import { formatPathForUser, hasUnityCommandLineFlag } from "./src/unity-core";
23
- import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
23
+ import { createUnityCliBatchmodeReportArgs, createUnityCliEditorExitCommand, createUnityCliRunCommand, createUnityCliTestCommand, dispatchUnityPipelineRunScript, dispatchUnityPlanningInspection, haveSameKnownProcessIds, inspectUnityCliProjectCapabilities, listRunningUnityCliEditorsForProject, resolveUnityCliCommand, UNITY_PLANNING_READ_COMMANDS, type UnityCliProjectCapabilities } from "./src/unity-cli";
24
24
  import { launchUnityCliOpenDetached } from "./src/unity-launch";
25
25
  import { createUnityBatchmodeCommand, launchUnityEditorDetached, resolveUnityEditorPath } from "./src/unity-editor-fallback";
26
26
  import { loadPiUnitySettings, type PiUnitySettings } from "./src/pi-unity-settings";
@@ -70,7 +70,7 @@ const GUI_WARNING = "This launches the full Unity Editor GUI and is not the same
70
70
  const SINGLE_PROCESS_WARNING = "Unity allows only one process per project folder. GUI Editor and batchmode/headless both count as that one process.";
71
71
 
72
72
  type UnityToolDetails = {
73
- mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline" | "tests";
73
+ mode: "gui" | "batchmode" | "status" | "artifacts" | "pipeline_inspection" | "pipeline_eval" | "pipeline_run_script" | "pipeline" | "tests";
74
74
  projectRoot: string;
75
75
  unityVersion: string;
76
76
  editorPath: string;
@@ -101,6 +101,7 @@ type UnityToolDetails = {
101
101
  cliCapabilities?: UnityCliProjectCapabilities;
102
102
  pipelineInspection?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
103
103
  pipelineEval?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
104
+ pipelineRunScript?: { outcome: "dispatched"; command: string; output: string; truncated: boolean } | { outcome: "rejected"; code: string; message: string };
104
105
  pipeline?: UnityPipelineOperationDetails;
105
106
  };
106
107
 
@@ -167,6 +168,15 @@ const PIPELINE_EVAL_PARAMS = Type.Object({
167
168
  timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
168
169
  }, { additionalProperties: false });
169
170
 
171
+ const PIPELINE_RUN_SCRIPT_PARAMS = Type.Object({
172
+ path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
173
+ file: Type.String({ minLength: 1, maxLength: 1000, description: "Existing .cs file under the selected project root. The file is compiled in memory; it is not written or imported." }),
174
+ entry: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Named static entry point. Omit only when Pipeline can select Main or an unambiguous public static method." })),
175
+ args: Type.Optional(Type.Array(Type.Any(), { maxItems: 32, description: "Bounded JSON arguments coerced by Pipeline to the static entry-point parameter types." })),
176
+ dryRun: Type.Optional(Type.Boolean({ default: false, description: "Compile only; do not load the assembly or invoke the entry point." })),
177
+ timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 30, description: "Connected run_script deadline. A timeout is uncertain and does not cancel the script." })),
178
+ }, { additionalProperties: false });
179
+
170
180
  const PIPELINE_INSPECTION_PARAMS = Type.Object({
171
181
  path: Type.Optional(Type.String({ description: "Unity project path, workspace copy root, or folder containing project copies." })),
172
182
  command: StringEnum(UNITY_PLANNING_READ_COMMANDS, { description: "An advertised package-owned Pipeline inspection command." }),
@@ -1294,9 +1304,9 @@ function renderUnityPipelineResult(result: any, options: { expanded: boolean; is
1294
1304
  const counts = pipeline.counts;
1295
1305
  const passed = counts?.passed === undefined || counts?.total === undefined ? "tests completed" : `${counts.passed}/${counts.total} passed`;
1296
1306
  text = `${icon} ${theme.fg("toolTitle", theme.bold(`Unity ${pipeline.testPlatform ?? ""} tests`.trim()))} ${theme.fg("accent", passed)}${theme.fg("muted", ` • ${pipeline.elapsedSeconds.toFixed(1)}s`)}`;
1297
- } else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection") {
1298
- const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.pipelineInspection;
1299
- const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : "Unity Pipeline Inspection";
1307
+ } else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection" || details.mode === "pipeline_run_script") {
1308
+ const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.mode === "pipeline_run_script" ? details.pipelineRunScript : details.pipelineInspection;
1309
+ const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : details.mode === "pipeline_run_script" ? "Unity Pipeline Run Script" : "Unity Pipeline Inspection";
1300
1310
  const summary = output?.outcome === "dispatched" ? output.output || "(no bounded output returned)" : output?.message || primaryText;
1301
1311
  text = `${icon} ${theme.fg("toolTitle", theme.bold(label))}\n ${theme.fg("toolOutput", compactUnityRendererValue(summary, 240))}`;
1302
1312
  } else {
@@ -1337,7 +1347,9 @@ function renderUnityToolResult(result: any, expanded: boolean, theme: any): Text
1337
1347
  ? "Unity Pipeline Inspection"
1338
1348
  : details.mode === "pipeline_eval"
1339
1349
  ? "Unity Pipeline Eval"
1340
- : details.mode === "pipeline"
1350
+ : details.mode === "pipeline_run_script"
1351
+ ? "Unity Pipeline Run Script"
1352
+ : details.mode === "pipeline"
1341
1353
  ? "Unity Pipeline"
1342
1354
  : getBatchmodeVariantLabel(details.args);
1343
1355
  const projectLabel = details.projectRoot ?? "(unknown project)";
@@ -1391,7 +1403,8 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1391
1403
  pi.on("tool_result", (event) => {
1392
1404
  const details = event.details as UnityToolDetails | undefined;
1393
1405
  if ((event.toolName === "unity_pipeline_eval" && details?.mode === "pipeline_eval" && details.pipelineEval?.outcome === "rejected")
1394
- || (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")) {
1406
+ || (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")
1407
+ || (event.toolName === "unity_pipeline_run_script" && details?.mode === "pipeline_run_script" && details.pipelineRunScript?.outcome === "rejected")) {
1395
1408
  return { isError: true };
1396
1409
  }
1397
1410
  });
@@ -1701,6 +1714,38 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1701
1714
  },
1702
1715
  });
1703
1716
 
1717
+ pi.registerTool({
1718
+ name: "unity_pipeline_run_script",
1719
+ label: "Unity Pipeline Run Script",
1720
+ description: "Compile one existing project C# file in memory and invoke its advertised static entry point through Pipeline 0.6 run_script.",
1721
+ promptSnippet: "Run one explicitly requested existing C# builder script through an already-open exact Unity Pipeline Editor.",
1722
+ promptGuidelines: [
1723
+ "unity_pipeline_run_script is arbitrary code execution, not read-only and not a sandbox. Use it only for explicit user intent; obtain explicit authorization for lifecycle, settings, asset, build, test, or destructive mutations.",
1724
+ "It uses Pipeline's ephemeral mode only: no hotpatch, no source upload, no asset import, no domain reload, no retry, fallback, cancellation, launch, save, or Play Mode exit.",
1725
+ "Use dryRun=true to compile without loading or executing the script. A failure, malformed result, or timeout never establishes success; a timeout may still have running script effects.",
1726
+ ],
1727
+ parameters: PIPELINE_RUN_SCRIPT_PARAMS,
1728
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1729
+ throwIfAborted(signal);
1730
+ const { candidate } = await resolveProjectCandidate(ctx, params.path);
1731
+ const file = isAbsolute(params.file) ? params.file : resolve(candidate.projectRoot, params.file);
1732
+ const result = await dispatchUnityPipelineRunScript({
1733
+ projectRoot: candidate.projectRoot,
1734
+ unityVersion: await requireManualUnityVersion(candidate),
1735
+ file,
1736
+ entry: params.entry,
1737
+ args: params.args,
1738
+ dryRun: params.dryRun,
1739
+ }, { execute: createPlanningUnityCliExecutor(pi), signal, timeout: (params.timeoutSeconds ?? 30) * 1000 });
1740
+ const text = result.outcome === "dispatched"
1741
+ ? `Unity Pipeline run_script ${params.dryRun ? "compile-only completed" : "completed"}.\n${result.output || "(no bounded output returned)"}`
1742
+ : `Unity Pipeline run_script rejected: ${result.code}\n${result.message}`;
1743
+ return { content: [{ type: "text", text }], details: { mode: "pipeline_run_script", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: result.outcome === "dispatched" ? "passed" : "failed", pipelineRunScript: result } satisfies UnityToolDetails };
1744
+ },
1745
+ renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_script", args, theme, context); },
1746
+ renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1747
+ });
1748
+
1704
1749
  pi.registerTool({
1705
1750
  name: "unity_pipeline_inspect",
1706
1751
  label: "Unity Pipeline Inspect",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -16,7 +16,7 @@ Use one typed tool call for each supported connected operation:
16
16
 
17
17
  These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
18
18
 
19
- A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. The only automatic retry is Pipeline 0.5's explicit initial-settling `Server Busy` response for `unity_pipeline_recompile` or `unity_run_tests`, which confirms that a main-thread command was rejected before dispatch.
19
+ A timeout, malformed response, or busy response is uncertain: the Unity operation may still be running or blocked by a modal dialog. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision. Pipeline 0.6 uses busy for modal blocks and no source-verified discriminator is available through this transport, so pi-unity does not blindly retry it.
20
20
 
21
21
  ## Preconditions and boundaries
22
22
 
@@ -67,4 +67,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
67
67
 
68
68
  Use `unity_run_tests` with `execution: "isolated"` for a closed project, intentional isolation/CI, multiple selectors in one run, retries, sharding, coverage, or required NUnit/JUnit evidence. Multiple independent fixtures alone do not require closing a reachable Editor: use the serial recipe above. A single category is supported connected when Pipeline advertises the command; do not claim broader connected selector support. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
69
69
 
70
- Use the typed compile/test tools when their polling and terminal evidence fit the task. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
70
+ Use the typed compile/test tools when their polling and terminal evidence fit the task. For an explicitly requested existing C# builder file, `unity_pipeline_run_script` uses Pipeline 0.6 ephemeral in-memory compilation with bounded JSON arguments or compile-only dry run; it never enables hotpatch and remains arbitrary code execution requiring explicit mutation authorization. Advertised Pipeline `eval` remains available through `unity_pipeline_eval` for bounded project-specific inspection or operations outside those typed workflows; its `timeoutSeconds` range is 1–86,400 seconds, and a timeout remains uncertain without cancellation or retry. It is an assistance surface, not a forbidden fallback or a substitute for the typed tools' completion protocol. Eval compiles arbitrary C# with Roslyn on the Editor main thread, so ordinary properties and local-variable snippets are valid; it is not expression-only or reliably statically read-only. Prefer typed tools for their stronger evidence, but let user intent and project guidance govern mutations. Lifecycle, persistent-setting, destructive, asset, scene-save, package, build, and test mutations require explicit authorization.
package/src/unity-cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile, realpath } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, join, relative } from "node:path";
4
4
  import { applyDefaultUnityBatchmodeArgs, buildUnityBatchmodeArgs, projectPathsMatch } from "./unity-core";
5
5
  import type { RunningUnityProcess } from "./unity-processes";
6
6
 
@@ -265,10 +265,10 @@ export function parseUnityCliStatusOutput(output: string, projectRoot: string):
265
265
 
266
266
  export async function listRunningUnityCliEditorsForProject(
267
267
  projectRoot: string,
268
- options: { cliCommand?: string; timeout?: number } = {},
268
+ options: { cliCommand?: string; timeout?: number; execute?: UnityCliExecutor } = {},
269
269
  ): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
270
270
  const command = resolveUnityCliCommand(options);
271
- const result = await execFileCollect(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
271
+ const result = await (options.execute ?? execFileCollect)(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
272
272
  timeout: options.timeout ?? 5000,
273
273
  });
274
274
 
@@ -277,11 +277,14 @@ export async function listRunningUnityCliEditorsForProject(
277
277
  }
278
278
 
279
279
  const processes = parseUnityCliStatusOutput(result.stdout, projectRoot);
280
- if (processes.length > 0) {
281
- return { processes };
280
+ const payload = parseJsonObject(result.stdout);
281
+ const statusWarnings = envelopeMessages(payload, "warnings");
282
+ if (statusWarnings.length > 0) {
283
+ return { processes, warning: `Unity CLI status response is incomplete; process absence is uncertain: ${statusWarnings.join("; ")}` };
282
284
  }
285
+ if (processes.length > 0) return { processes };
286
+
283
287
 
284
- const payload = parseJsonObject(result.stdout);
285
288
  const errors = Array.isArray(payload?.errors) ? payload.errors : [];
286
289
  const onlyNoInstances = errors.some((entry) => getRecord(entry)?.code === "STATUS_NO_INSTANCES");
287
290
  if (result.error && !onlyNoInstances) {
@@ -430,14 +433,27 @@ export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): bo
430
433
  return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
431
434
  }
432
435
 
436
+ const UNITY_CLI_MAX_DIAGNOSTICS = 8;
437
+ function envelopeMessages(payload: Record<string, unknown> | null, fieldName: "errors" | "warnings" | "info"): string[] {
438
+ const entries = Array.isArray(payload?.[fieldName]) ? payload[fieldName] : [];
439
+ return entries.slice(0, UNITY_CLI_MAX_DIAGNOSTICS).flatMap((entry): string[] => {
440
+ const item = getRecord(entry);
441
+ const message = optionalString(item?.message, item?.detail, typeof entry === "string" ? entry : undefined);
442
+ return message ? [redactUnityPlanningOutput(summarizeUnityCliText(message, 1_000, 10))] : [];
443
+ });
444
+ }
445
+
446
+ /** Warnings make discovery incomplete; informational descriptor/envelope notes do not. */
447
+ function cliEnvelopeDiagnostics(payload: Record<string, unknown> | null): string[] {
448
+ return envelopeMessages(payload, "warnings").map(message => `warning: ${message}`);
449
+ }
450
+ function cliEnvelopeInfo(payload: Record<string, unknown> | null): string[] {
451
+ return envelopeMessages(payload, "info").map(message => `info: ${message}`);
452
+ }
453
+
433
454
  function cliFailureMessage(result: UnityCliExecResult): string | undefined {
434
455
  const payload = parseJsonObject(result.stdout);
435
- const errors = Array.isArray(payload?.errors) ? payload.errors : [];
436
- const messages = errors.flatMap((entry): string[] => {
437
- const message = optionalString(getRecord(entry)?.message);
438
- return message ? [message] : [];
439
- });
440
- const message = messages[0] ?? (result.stderr.trim() || result.error?.message);
456
+ const message = envelopeMessages(payload, "errors")[0] ?? (result.stderr.trim() || result.error?.message);
441
457
  return message ? summarizeUnityCliText(message) : undefined;
442
458
  }
443
459
 
@@ -467,7 +483,9 @@ export async function inspectUnityCliProjectCapabilities(
467
483
  const command = resolveUnityCliCommand(options);
468
484
  const versionTimeout = options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS;
469
485
  const discoveryTimeout = options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS;
470
- const versionResult = await execute(command, ["--version"], { timeout: versionTimeout, signal: options.signal });
486
+ // Explicitly select the supported human formatter: inherited UNITY_FORMAT must not turn
487
+ // --version into JSON (or another representation) and corrupt capability reporting.
488
+ const versionResult = await execute(command, ["--format", "human", "--version"], { timeout: versionTimeout, signal: options.signal });
471
489
  if (versionResult.error && (versionResult.error as NodeJS.ErrnoException).code === "ENOENT") return result;
472
490
  if (versionResult.error) {
473
491
  result.warnings.push(`Unity CLI version probe ${isUnityCliTimeout(versionResult) ? "timed out" : "failed"}: ${cliFailureMessage(versionResult) ?? "unknown error"}`);
@@ -479,9 +497,15 @@ export async function inspectUnityCliProjectCapabilities(
479
497
  const pipelineResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "pipeline", "list"], { timeout: discoveryTimeout, signal: options.signal });
480
498
  const pipelinePayload = parseJsonObject(pipelineResult.stdout);
481
499
  const pipelineData = getRecord(pipelinePayload?.data);
482
- if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances)) {
500
+ const pipelineDiagnostics = cliEnvelopeDiagnostics(pipelinePayload);
501
+ result.warnings.push(...cliEnvelopeInfo(pipelinePayload));
502
+ if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances) || pipelineDiagnostics.length > 0) {
483
503
  result.pipelineDiscovery = isUnityCliTimeout(pipelineResult) ? "timeout" : "unavailable";
484
- result.warnings.push(`Unity Pipeline instance discovery ${result.pipelineDiscovery === "timeout" ? "timed out; Pipeline startup state is uncertain" : "failed"}: ${cliFailureMessage(pipelineResult) ?? "malformed or unsupported JSON response"}`);
504
+ const diagnostic = pipelineDiagnostics.join("; ") || cliFailureMessage(pipelineResult) || "malformed or unsupported JSON response";
505
+ result.warnings.push(`Unity Pipeline instance discovery ${result.pipelineDiscovery === "timeout" ? "timed out; Pipeline startup state is uncertain" : "is incomplete or failed; Pipeline startup state is uncertain"}: ${diagnostic}`);
506
+ // Retain any known positive exact-copy instance descriptors, but never use this
507
+ // incomplete response as a safe launch or connected-dispatch signal.
508
+ result.matchingInstances = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot).instances;
485
509
  return result;
486
510
  }
487
511
  result.pipelineDiscovery = "available";
@@ -500,9 +524,17 @@ export async function inspectUnityCliProjectCapabilities(
500
524
  result.commandDiscoveryAttempted = true;
501
525
  const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "list", "--project-path", projectRoot], { timeout: discoveryTimeout, signal: options.signal });
502
526
  const catalog = parseUnityCliCommandCatalog(listResult.stdout);
503
- if (listResult.error || !catalog.valid) {
527
+ const listPayload = parseJsonObject(listResult.stdout);
528
+ const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
529
+ result.warnings.push(...cliEnvelopeInfo(listPayload));
530
+ if (listResult.error || !catalog.valid || commandDiagnostics.length > 0) {
504
531
  result.commandDiscovery = isUnityCliTimeout(listResult) ? "timeout" : "unavailable";
505
- result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "failed"}: ${cliFailureMessage(listResult) ?? "malformed or unsupported JSON response"}`);
532
+ result.warnings.push(`Unity Pipeline command discovery for the exact project copy ${result.commandDiscovery === "timeout" ? "timed out; command availability is uncertain" : "is incomplete or failed; command availability is uncertain"}: ${commandDiagnostics.join("; ") || cliFailureMessage(listResult) || "malformed or unsupported JSON response"}`);
533
+ // Commands in a warning-bearing catalog are informational only, not advertised
534
+ // capability evidence. Keep descriptors for status visibility without enabling dispatch.
535
+ result.advertisedCommands = catalog.commands;
536
+ result.advertisedCommandCount = catalog.total;
537
+ result.advertisedCommandsTruncated = catalog.truncated;
506
538
  return result;
507
539
  }
508
540
  result.commandDiscovery = "available";
@@ -522,6 +554,7 @@ export const UNITY_PLANNING_READ_COMMANDS = Object.freeze([
522
554
  "get_authoring_root",
523
555
  "get_build_settings",
524
556
  "get_player_settings",
557
+ "get_runtime_pipeline_settings",
525
558
  "get_scene_hierarchy",
526
559
  "editor_status",
527
560
  "list_open_scenes",
@@ -542,6 +575,21 @@ export type UnityPlanningInspectionResult =
542
575
  | { outcome: "dispatched"; command: string; output: string; truncated: boolean }
543
576
  | { outcome: "rejected"; code: string; message: string };
544
577
 
578
+ export type UnityPipelineRunScriptRequest = {
579
+ projectRoot: string;
580
+ unityVersion: string;
581
+ file: string;
582
+ entry?: string;
583
+ args?: unknown[];
584
+ dryRun?: boolean;
585
+ };
586
+
587
+ /** Attach bounded native discovery guidance without changing the readiness decision. */
588
+ export function unityCapabilityDiagnosticSuffix(capabilities: UnityCliProjectCapabilities): string {
589
+ const message = summarizeUnityCliText(redactUnityPlanningOutput(capabilities.warnings.slice(0, 8).join("; ")), 1_000, 10);
590
+ return message ? ` ${message}` : "";
591
+ }
592
+
545
593
  function planningInspectionReadiness(capabilities: UnityCliProjectCapabilities): string | undefined {
546
594
  if (!capabilities.cliAvailable) return "unity_cli_unavailable";
547
595
  if (capabilities.pipelineDiscovery !== "available") return `pipeline_${capabilities.pipelineDiscovery}`;
@@ -556,12 +604,32 @@ function caseInsensitiveField(record: Record<string, unknown>, name: string): un
556
604
  return entry?.[1];
557
605
  }
558
606
 
607
+ function runScriptCommandFailure(output: string): "malformed" | "failure" | undefined {
608
+ const envelope = parseJsonObject(output);
609
+ if (!envelope || caseInsensitiveField(envelope, "success") !== true) return envelope ? "failure" : "malformed";
610
+ const wrapper = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
611
+ if (caseInsensitiveField(wrapper, "success") === false) return "failure";
612
+ let response: unknown = caseInsensitiveField(wrapper, "result");
613
+ // Legacy wrapper puts the documented RunScriptResponse in data.result; compact
614
+ // transport puts it in result. A bare success envelope has no command evidence.
615
+ if (typeof response === "string") { try { response = JSON.parse(response); } catch { return "malformed"; } }
616
+ const result = getRecord(response);
617
+ if (!result || !Object.prototype.hasOwnProperty.call(result, "diagnostics") || !Array.isArray(caseInsensitiveField(result, "diagnostics"))) return "malformed";
618
+ if (caseInsensitiveField(result, "success") === false || caseInsensitiveField(result, "failed") === true) return "failure";
619
+ const diagnostics = caseInsensitiveField(result, "diagnostics") as unknown[];
620
+ if (diagnostics.some(item => {
621
+ const diagnostic = getRecord(item);
622
+ return String(caseInsensitiveField(diagnostic ?? {}, "severity") ?? "").toLowerCase() === "error";
623
+ })) return "failure";
624
+ return undefined;
625
+ }
626
+
559
627
  function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
560
628
  const envelope = parseJsonObject(output);
561
629
  if (!envelope) return "malformed";
562
630
  if (caseInsensitiveField(envelope, "success") !== true) return "failure";
563
- const data = getRecord(caseInsensitiveField(envelope, "data"));
564
- if (!data) return "malformed";
631
+ // Pipeline 0.6 compact responses omit the CLI wrapper's data field.
632
+ const data = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
565
633
  if (caseInsensitiveField(data, "success") === false) return "failure";
566
634
  if (!isEval) return undefined;
567
635
 
@@ -610,7 +678,7 @@ export async function dispatchUnityPlanningInspection(
610
678
  }));
611
679
  const initial = await inspect(projectRoot, request.unityVersion);
612
680
  const initialFailure = planningInspectionReadiness(initial);
613
- if (initialFailure) return { outcome: "rejected", code: initialFailure, message: "Exact-copy Pipeline planning inspection is not established." };
681
+ if (initialFailure) return { outcome: "rejected", code: initialFailure, message: `Exact-copy Pipeline planning inspection is not established.${unityCapabilityDiagnosticSuffix(initial)}` };
614
682
 
615
683
  const isEval = request.command === "eval";
616
684
  const hasBoundedArgs = (request.args?.length ?? 0) <= 12
@@ -633,7 +701,7 @@ export async function dispatchUnityPlanningInspection(
633
701
  const refreshed = await inspect(projectRoot, request.unityVersion);
634
702
  const refreshedFailure = planningInspectionReadiness(refreshed);
635
703
  if (refreshedFailure || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances)) {
636
- return { outcome: "rejected", code: "unity_project_identity_changed", message: "Pipeline identity changed or disconnected immediately before planning dispatch." };
704
+ return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity changed or disconnected immediately before planning dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
637
705
  }
638
706
  if (!refreshed.advertisedCommands.includes(request.command)) {
639
707
  return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
@@ -663,6 +731,37 @@ export async function dispatchUnityPlanningInspection(
663
731
  return { outcome: "dispatched", command: request.command, output, truncated: output.length < raw.trim().length };
664
732
  }
665
733
 
734
+ /** Dispatch the documented Pipeline 0.6 ephemeral run_script form; hotpatch is deliberately not exposed. */
735
+ export async function dispatchUnityPipelineRunScript(
736
+ request: UnityPipelineRunScriptRequest,
737
+ options: { cliCommand?: string; timeout?: number; signal?: AbortSignal; execute: UnityCliExecutor; inspect?: (projectRoot: string, unityVersion: string) => Promise<UnityCliProjectCapabilities> },
738
+ ): Promise<UnityPlanningInspectionResult> {
739
+ let projectRoot: string; let file: string;
740
+ try { projectRoot = await realpath(request.projectRoot); file = await realpath(request.file); } catch {
741
+ return { outcome: "rejected", code: "run_script_path_unavailable", message: "The project root or existing script file could not be canonicalized." };
742
+ }
743
+ const relativeFile = relative(projectRoot, file);
744
+ let fileStats: Awaited<ReturnType<typeof stat>>;
745
+ try { fileStats = await stat(file); } catch { return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one readable existing C# file." }; }
746
+ if (!relativeFile || isAbsolute(relativeFile) || relativeFile === ".." || relativeFile.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || !fileStats.isFile() || !/\.cs$/i.test(relativeFile)) return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one existing .cs file inside the exact project root." };
747
+ let serializedArgs: string;
748
+ try { serializedArgs = JSON.stringify(request.args ?? []); } catch { return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script arguments must be JSON-serializable." }; }
749
+ if (serializedArgs === undefined || serializedArgs.length > 4_000 || (request.entry?.length ?? 0) > 500 || /[\u0000-\u001f\u007f]/.test(request.entry ?? "")) return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script entry or JSON arguments exceed bounded request limits." };
750
+ const inspect = options.inspect ?? ((root, version) => inspectUnityCliProjectCapabilities(root, version, { cliCommand: options.cliCommand, timeout: options.timeout, signal: options.signal, execute: options.execute }));
751
+ const initial = await inspect(projectRoot, request.unityVersion);
752
+ if (planningInspectionReadiness(initial) || !initial.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "run_script_unavailable", message: `The exact reachable Pipeline copy does not establish advertised run_script support.${unityCapabilityDiagnosticSuffix(initial)}` };
753
+ const refreshed = await inspect(projectRoot, request.unityVersion);
754
+ if (planningInspectionReadiness(refreshed) || !haveSameKnownProcessIds(initial.matchingInstances, refreshed.matchingInstances) || !refreshed.advertisedCommands.includes("run_script")) return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity or run_script availability changed immediately before dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
755
+ const timeout = Math.max(1, Math.min(options.timeout ?? 30_000, 86_400_000));
756
+ const args = ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--timeout", String(Math.ceil(timeout / 1000)), "run_script", "--file", relativeFile, "--mode", "ephemeral", "--args", serializedArgs, "--timeout_ms", String(timeout), ...(request.entry?.trim() ? ["--entry", request.entry.trim()] : []), ...(request.dryRun ? ["--dry_run", "true"] : [])];
757
+ const execution = await options.execute(resolveUnityCliCommand({ cliCommand: options.cliCommand }), args, { timeout, signal: options.signal });
758
+ const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n"); const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
759
+ if (execution.error) return { outcome: "rejected", code: isUnityCliTimeout(execution) ? "run_script_timeout" : "run_script_failed", message: `run_script did not complete successfully; its effect may be uncertain.${output ? ` ${output}` : ""}` };
760
+ const failure = runScriptCommandFailure(execution.stdout);
761
+ if (failure) return { outcome: "rejected", code: failure === "malformed" ? "run_script_malformed" : "run_script_reported_failure", message: `${failure === "malformed" ? "run_script returned malformed JSON evidence" : "run_script reported failure"}.${output ? ` ${output}` : ""}` };
762
+ return { outcome: "dispatched", command: "run_script", output, truncated: output.length < raw.trim().length };
763
+ }
764
+
666
765
  /** Keep connected inspection output useful without returning common credential forms verbatim. */
667
766
  export function redactUnityPlanningOutput(value: string): string {
668
767
  return value
@@ -1,6 +1,6 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { projectPathsMatch } from "./unity-core";
3
- import { resolveUnityCliCommand, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
3
+ import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
4
4
 
5
5
  /** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
6
6
  export const UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS = 180;
@@ -31,6 +31,8 @@ export type UnityPipelineOperationDetails = {
31
31
  testPlatform?: "EditMode" | "PlayMode";
32
32
  testFilter?: string;
33
33
  counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
34
+ /** Bounded nonfatal Pipeline envelope guidance; never compiler-error evidence. */
35
+ warnings?: string[];
34
36
  };
35
37
  export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
36
38
  /** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
@@ -72,6 +74,39 @@ function bounded(value: string, limit = UNITY_PIPELINE_MAX_STACK_CHARS): string
72
74
  const oneLine = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
73
75
  return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
74
76
  }
77
+ function pipelineEnvelopeWarnings(output: string): string[] {
78
+ const outer = (() => { try { return record(JSON.parse(output)); } catch { return undefined; } })();
79
+ const data = record(outer?.data);
80
+ const entries = [outer?.warnings, data?.warnings].flatMap(value => Array.isArray(value) ? value.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS) : []);
81
+ return entries.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS).flatMap(entry => {
82
+ const item = record(entry); const message = string(field(item ?? {}, "message", "detail", "warning")) ?? string(entry);
83
+ return message ? [bounded(redactUnityPlanningOutput(message), 1_000)] : [];
84
+ });
85
+ }
86
+ function retainWarnings(warnings: string[], output: string): void {
87
+ for (const warning of pipelineEnvelopeWarnings(output)) {
88
+ if (warnings.length >= UNITY_PIPELINE_MAX_DIAGNOSTICS) break;
89
+ if (!warnings.includes(warning)) warnings.push(warning);
90
+ }
91
+ }
92
+ function warningText(warnings: string[]): string {
93
+ return warnings.length ? `\nPipeline warnings: ${warnings.join("; ")}` : "";
94
+ }
95
+ function pipelineFailureDiagnostic(response: UnityCliExecResult): string {
96
+ const raw = [response.stdout, response.stderr, response.error?.message].filter(Boolean).join("\n");
97
+ let message: string | undefined;
98
+ try {
99
+ const outer = record(JSON.parse(response.stdout)); const data = record(outer?.data);
100
+ const errors = Array.isArray(outer?.errors) ? outer?.errors : [];
101
+ const first = record(errors[0]);
102
+ const messages = [
103
+ string(field(first ?? {}, "code")), string(field(first ?? {}, "message", "detail")),
104
+ ...[data, outer].flatMap(item => ["error", "errordetails", "message"].map(key => string(field(item ?? {}, key)))),
105
+ ].filter((value): value is string => Boolean(value));
106
+ message = [...new Set(messages)].join("; ") || undefined;
107
+ } catch { /* retain bounded native raw fallback */ }
108
+ return summarizeUnityCliText(redactUnityPlanningOutput(message ?? raw), 1_000, 10);
109
+ }
75
110
  function throwIfAborted(signal?: AbortSignal): void {
76
111
  if (signal?.aborted) throw new Error("Unity Pipeline operation aborted; its Editor operation may still be running.");
77
112
  }
@@ -102,8 +137,10 @@ export function parseUnityPipelineEnvelope(output: string): ParsedEnvelope {
102
137
  let outer: RecordValue | undefined;
103
138
  try { outer = record(JSON.parse(output)); } catch { return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned malformed JSON." }; }
104
139
  if (!outer) return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned a non-object JSON envelope." };
140
+ // Pipeline 0.6 may return the compact exec envelope { success, result, warnings }
141
+ // rather than the older CLI wrapper { success, data: { result } }.
105
142
  const data = record(outer.data);
106
- const rawResult = data?.result ?? data;
143
+ const rawResult = data?.result ?? outer.result ?? data;
107
144
  if (typeof rawResult === "string") {
108
145
  try {
109
146
  const parsed = record(JSON.parse(rawResult));
@@ -148,15 +185,14 @@ function diagnostics(result: RecordValue): string[] {
148
185
  });
149
186
  return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
150
187
  }
151
- /** True only for Pipeline 0.5's explicit rejected outer envelope, before a main-thread command is dispatched. */
152
- export function isUnityPipelineInitialSettlingBusy(output: string): boolean {
153
- let outer: RecordValue | undefined;
154
- try { outer = record(JSON.parse(output)); } catch { return false; }
155
- const data = record(outer?.data);
156
- return outer?.success === false
157
- && string(field(data ?? {}, "error"))?.toLowerCase() === "server busy"
158
- && statusOf(data ?? {}) === "busy"
159
- && field(data ?? {}, "retryable") === true;
188
+ /**
189
+ * Do not retry generic busy envelopes. Pipeline 0.6 uses busy for modal dialogs as
190
+ * well as startup settling, and neither is distinguishable in the legacy CLI-shaped
191
+ * response. Retrying would blindly repeat a command blocked by a user-visible modal.
192
+ * A future retry must be wired only to a source-verified pre-dispatch discriminator.
193
+ */
194
+ export function isUnityPipelineInitialSettlingBusy(_output: string): boolean {
195
+ return false;
160
196
  }
161
197
 
162
198
  export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
@@ -363,7 +399,7 @@ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot
363
399
 
364
400
  async function requirePreflight(deps: PipelineDependencies, projectRoot: string, unityVersion: string, commands: string[], operation: "recompile" | "tests", signal: AbortSignal | undefined, deadline: number, now: () => number, allowAutonomousExitPlayMode = true): Promise<{ capabilities: UnityCliProjectCapabilities; exitedPlayMode: boolean; playModeHandling: UnityPipelinePlayModeHandling; scriptChangesWhilePlaying?: UnityScriptChangesWhilePlayingPolicy }> {
365
401
  const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
366
- const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
402
+ const error = capabilityError(capabilities, commands); if (error) throw new Error(error + unityCapabilityDiagnosticSuffix(capabilities));
367
403
  let editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
368
404
  if (editor.error) throw new Error("Unity Pipeline editor_status failed; operation not started.");
369
405
  let status = editorStatus(editor.stdout);
@@ -450,11 +486,12 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
450
486
  ensureBeforeDeadline(deadline, now, "recompile before dispatch");
451
487
  throwIfAborted(signal);
452
488
  const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
453
- if (dispatched.error) throw new Error("Unity Pipeline recompile dispatch failed; operation may not have started.");
489
+ if (dispatched.error) throw new Error(`Unity Pipeline recompile dispatch failed; operation may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
490
+ const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
454
491
  let state = normalizeUnityPipelineCompile(dispatched.stdout);
455
492
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
456
493
  if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
457
- if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered.`, details: { projectRoot, operation: "recompile", terminalState: "up_to_date", elapsedSeconds: elapsed(start, now), compilationTriggered: false, ...playModeDetails(preflight) } };
494
+ if (state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity scripts are up to date for ${projectRoot}; no compilation was triggered.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: "up_to_date", elapsedSeconds: elapsed(start, now), compilationTriggered: false, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
458
495
  for (let poll = 0; now() < deadline; poll += 1) {
459
496
  options.onUpdate?.(`Unity recompile ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
460
497
  const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
@@ -467,9 +504,10 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
467
504
  const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
468
505
  if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
469
506
  if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
507
+ retainWarnings(dispatchWarnings, response.stdout);
470
508
  state = normalizeUnityPipelineCompile(response.stdout);
471
509
  if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
472
- if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...playModeDetails(preflight) } };
510
+ if (state.state === "completed" || state.state === "up_to_date") return { text: `${lifecyclePrefix}Unity recompile completed for ${projectRoot} in ${elapsed(start, now).toFixed(1)}s; 0 compiler errors.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "recompile", terminalState: state.state, elapsedSeconds: elapsed(start, now), compilationTriggered: true, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}), ...playModeDetails(preflight) } };
473
511
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile status is malformed or uncertain; operation may still be running.");
474
512
  }
475
513
  throw timeoutMessage("recompile");
@@ -498,7 +536,8 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
498
536
  const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
499
537
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
500
538
  const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
501
- if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
539
+ if (dispatched.error) throw new Error(`Unity Pipeline test dispatch failed; test run may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
540
+ const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
502
541
  let state = normalizeUnityPipelineTest(dispatched.stdout);
503
542
  if (state.state === "uncertain" || state.state === "inactive") throw new Error("Unity Pipeline test dispatch returned inactive, malformed, or uncertain evidence; test run may not have started.");
504
543
  if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
@@ -509,7 +548,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
509
548
  if (state.state === "completed") {
510
549
  const counts = passingCounts(state);
511
550
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
512
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
551
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
513
552
  }
514
553
  for (let poll = 0; now() < deadline; poll += 1) {
515
554
  options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
@@ -523,6 +562,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
523
562
  const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
524
563
  if (response.error) continue;
525
564
  if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
565
+ retainWarnings(dispatchWarnings, response.stdout);
526
566
  state = normalizeUnityPipelineTest(response.stdout);
527
567
  if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
528
568
  if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
@@ -531,7 +571,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
531
571
  if (state.state !== "completed") continue;
532
572
  const counts = passingCounts(state);
533
573
  if (!counts) throw new Error("Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).");
534
- return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts }, testRecords: state.testRecords };
574
+ return { text: `${lifecyclePrefix}Unity ${request.testPlatform} tests passed for ${projectRoot}: ${counts.total} executed, ${counts.passed} passed, 0 failed in ${elapsed(start, now).toFixed(2)}s.${warningText(dispatchWarnings)}`, details: { projectRoot, operation: "tests", terminalState: "completed", elapsedSeconds: elapsed(start, now), ...playModeDetails(preflight), testPlatform: request.testPlatform, testFilter: request.testFilter ?? request.testCategory, counts, ...(dispatchWarnings.length ? { warnings: dispatchWarnings } : {}) }, testRecords: state.testRecords };
535
575
  }
536
576
  throw timeoutMessage("tests");
537
577
  }