@aefree/pi-unity 0.12.1 → 0.14.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,26 @@ and this project follows semantic versioning for public package releases.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.14.0 - 2026-09-14
11
+
12
+ ### Fixed
13
+
14
+ - Preserve durable normalized connected-test evidence for terminal failed, cancelled, runner-error, and incomplete Pipeline results without replaying dispatch, overriding active Pipeline state from partial records, or misreporting inconsistent counts/records as passing.
15
+ - Select one primary latest normalized result and only its validated project-contained backend links when inspecting historical artifacts, including canonical contained aliases.
16
+ - Forward optional `unity_pipeline_eval.handlerTimeoutMilliseconds` only when the exact reachable Pipeline descriptor confirms raw argv support and the documented `code`/`timeout` signature. Preserve `timeoutSeconds` as the separate host/CLI wait; a dispatcher expiry cannot cancel eval code already started on Unity's main thread, so effects remain uncertain and are never retried or rerouted.
17
+ - Treat Pipeline 0.7's explicit `compilationFailed: true` response as a failed recompile even when its status is `up_to_date` or `completed` and no compiler-error array is present.
18
+
19
+ ## 0.13.0 - 2026-09-10
20
+
21
+ ### Added
22
+
23
+ - 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.
24
+
25
+ ### Fixed
26
+
27
+ - 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.
28
+ - 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.
29
+
10
30
  ## 0.12.1 - 2026-09-10
11
31
 
12
32
  ### Security
package/README.md CHANGED
@@ -34,12 +34,13 @@ Use these tools with an already-open exact Unity project copy that has a reachab
34
34
  - `unity_project_status` — inspect lockfiles, matching Unity processes, Pipeline reachability, package version, and advertised commands without launching Unity.
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
- - `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.
37
+ - `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL. `timeoutSeconds` bounds pi-unity and Unity CLI waits (1–86,400 seconds). Optional `handlerTimeoutMilliseconds` is forwarded only when the exact reachable Pipeline advertises raw argv plus the verified eval timeout signature; it bounds that Pipeline dispatcher wait, not code already running on Unity's main thread. A timeout is uncertain and does not cancel or retry Editor work.
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. Pipeline 0.7 improves recovery of standing compile errors and console output. For non-development Player builds, Pipeline also requires the `ENABLE_RUNTIME_PIPELINE` scripting define; enabling its runtime setting alone is insufficient.
43
44
 
44
45
  ### Editor and batchmode
45
46
 
@@ -47,7 +48,7 @@ A timeout is uncertain: work may still be running. The tools do not silently can
47
48
  - `unity_launch_batchmode` — run a bounded batchmode command through Unity CLI or the direct Editor executable.
48
49
  - `unity_inspect_artifacts` — validate existing normalized JSON test artifacts, Unity Test Framework XML and Unity logs without launching Unity. `details.status` describes inspection; `details.testOutcome` describes the tests, including failures and uncertainty. A valid failed-test artifact is a successful inspection, not a passing run.
49
50
 
50
- Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection; missing requested files fail inspection. With all paths omitted, `latestFromLogs` discovers recent files but cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
51
+ Pass `normalizedResultPath` for standalone JSON evidence. Any explicit artifact path disables implicit latest-file selection and link expansion; missing requested files fail inspection. With all paths omitted, `latestFromLogs` selects one newest top-level JSON (mtime then filename) and only its contained declared NUnit/log links. Without JSON it selects XML alone, then log context. Historical selection cannot establish current-run identity. Mixed JSON/XML evidence must agree; without an explicit native artifact link, matching counts alone leave correlation uncertain.
51
52
 
52
53
  Use `unity_run_tests` for all ordinary Unity Test Framework work. It writes a durable normalized JSON result under `Logs/`; isolated `unity test` runs retain requested native reports. Connected Pipeline is selected only for compatible requests. A reachable Editor is never closed automatically to obtain isolated-only features.
53
54
 
@@ -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.
@@ -111,8 +113,14 @@ Another connected client is not a project lock. When Pipeline returns stable cor
111
113
  { code: "var s = UnityEngine.Application.dataPath; return s.Length;" }
112
114
  ```
113
115
 
116
+ `timeoutSeconds` remains the host/CLI wait. On exact copies that advertise raw argv and the verified eval `code`/integer-`timeout` signature, `handlerTimeoutMilliseconds` (1–86,400,000) also sets Pipeline's dispatcher wait through its positional command argument. A shorter host timeout may still win. This server wait can prevent queued work from starting, but cannot cancel eval code that already began on Unity's main thread; treat expiry as uncertain and never retry or fall back.
117
+
114
118
  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
119
 
120
+ ### Pipeline run_script
121
+
122
+ `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.
123
+
116
124
  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
125
 
118
126
  ## Launch and process safeguards
@@ -230,7 +238,9 @@ The registry-clean `package-lock.json` is committed. Optional development packag
230
238
 
231
239
  ## Unity Pipeline project side effect
232
240
 
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.
241
+ 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.
242
+
243
+ 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
244
 
235
245
  ## License
236
246
 
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { mkdir, readFile, readdir, stat, unlink } from "node:fs/promises";
5
- import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { mkdir, readFile, readdir, realpath, stat, unlink } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
  import { getKeybindings, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
8
  import {
@@ -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";
@@ -32,7 +32,7 @@ import { applyUnityCliRetrySummary, compactUnityTestSummary, defaultUnityTestRep
32
32
  import { validateNormalizedUnityTestArtifact } from "./src/unity-artifact-inspection";
33
33
  import { UNITY_TEST_MAX_ARTIFACT_BYTES } from "./src/unity-tests";
34
34
  import { auditUnityGuidance, type UnityGuidanceAuditResult } from "./src/unity-guidance-audit";
35
- import { runUnityPipelineRecompile, runUnityPipelineTests, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
35
+ import { runUnityPipelineRecompile, runUnityPipelineTests, UnityPipelineTerminalTestEvidenceError, type UnityPipelineOperationDetails } from "./src/unity-pipeline";
36
36
  import {
37
37
  createOptionalIntegrationRegistryV1,
38
38
  isOptionalIntegrationActive,
@@ -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,7 +101,11 @@ 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;
106
+ testResult?: NormalizedUnityTestResult;
107
+ artifactPath?: string;
108
+ route?: "connected" | "isolated";
105
109
  };
106
110
 
107
111
  const LAUNCHER_SCHEMA = Type.Optional(StringEnum(["auto", "unity-cli", "editor-executable"] as const, { description: "Launch backend. Defaults to auto, which prefers the Unity CLI and falls back to direct editor executable launch when the CLI is unavailable." }));
@@ -164,7 +168,17 @@ const PIPELINE_TEST_PARAMS = Type.Object({
164
168
  const PIPELINE_EVAL_PARAMS = Type.Object({
165
169
  path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
166
170
  code: Type.String({ minLength: 1, maxLength: 4000, description: "Bounded C# source for advertised Pipeline eval. Roslyn compiles it on the connected Editor main thread; include an explicit return value when evidence is needed." }),
167
- 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." })),
171
+ timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400, default: 12, description: "Connected eval host/CLI deadline in seconds (maximum 24 hours). A timeout is uncertain and does not retry or cancel Unity work." })),
172
+ handlerTimeoutMilliseconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86400000, description: "Optional advertised Pipeline eval dispatcher wait in milliseconds. Forwarded only when the exact copy advertises raw argv and the verified code/timeout signature; it cannot cancel code already started on Unity's main thread." })),
173
+ }, { additionalProperties: false });
174
+
175
+ const PIPELINE_RUN_SCRIPT_PARAMS = Type.Object({
176
+ path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
177
+ 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." }),
178
+ 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." })),
179
+ args: Type.Optional(Type.Array(Type.Any(), { maxItems: 32, description: "Bounded JSON arguments coerced by Pipeline to the static entry-point parameter types." })),
180
+ dryRun: Type.Optional(Type.Boolean({ default: false, description: "Compile only; do not load the assembly or invoke the entry point." })),
181
+ 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." })),
168
182
  }, { additionalProperties: false });
169
183
 
170
184
  const PIPELINE_INSPECTION_PARAMS = Type.Object({
@@ -186,7 +200,7 @@ const INSPECT_ARTIFACTS_PARAMS = Type.Object({
186
200
  testResultsPath: Type.Optional(Type.String({ description: "Unity Test Framework XML results path. Relative paths are resolved against cwd and the Unity project root." })),
187
201
  normalizedResultPath: Type.Optional(Type.String({ description: "pi-unity normalized JSON test artifact path." })),
188
202
  logFilePath: Type.Optional(Type.String({ description: "Unity log file path. Relative paths are resolved against cwd and the Unity project root." })),
189
- latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, inspect the newest .json, .xml and .log files under Logs. Latest files are not proof of a shared run." })),
203
+ latestFromLogs: Type.Optional(Type.Boolean({ default: true, description: "Only when all artifact paths are omitted, select one newest top-level Logs JSON (mtime then filename) and only its declared contained NUnit/log links. Without JSON, select newest XML alone, otherwise newest log context. Latest files are not proof of a shared run." })),
190
204
  maxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 60, description: "Maximum log/output lines to include." })),
191
205
  maxChars: Type.Optional(Type.Integer({ minimum: 500, maximum: 20000, default: 6000, description: "Maximum log/output characters to include." })),
192
206
  });
@@ -676,8 +690,9 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
676
690
  let entries: Awaited<ReturnType<typeof readdir>>;
677
691
  try {
678
692
  entries = await readdir(root, { withFileTypes: true });
679
- } catch {
680
- return undefined;
693
+ } catch (error) {
694
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
695
+ throw error;
681
696
  }
682
697
 
683
698
  const files = await Promise.all(entries
@@ -687,7 +702,16 @@ async function findNewestFile(root: string, suffixes: string[]): Promise<string
687
702
  const stats = await stat(fullPath);
688
703
  return { fullPath, mtimeMs: stats.mtimeMs };
689
704
  }));
690
- return files.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.fullPath;
705
+ return files.sort((left, right) => right.mtimeMs - left.mtimeMs || left.fullPath.localeCompare(right.fullPath))[0]?.fullPath;
706
+ }
707
+
708
+ async function resolveLinkedArtifact(projectRoot: string, linkPath: string): Promise<string> {
709
+ const canonicalRoot = await realpath(projectRoot);
710
+ const candidate = resolve(projectRoot, linkPath);
711
+ const canonicalCandidate = await realpath(candidate);
712
+ const relativePath = relative(canonicalRoot, canonicalCandidate);
713
+ if (!relativePath || isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) throw new Error(`Linked artifact escapes the project root: ${linkPath}`);
714
+ return canonicalCandidate;
691
715
  }
692
716
 
693
717
  function resolveArtifactPath(cwd: string, projectRoot: string, value: string | undefined): string | undefined {
@@ -716,12 +740,14 @@ async function buildArtifactInspectionReport(
716
740
  // An exact artifact request must not silently recruit unrelated latest evidence.
717
741
  const useLatest = params.latestFromLogs !== false && ![params.testResultsPath, params.logFilePath, params.normalizedResultPath].some(value => value?.trim());
718
742
  const logsRoot = join(candidate.projectRoot, "Logs");
719
- const testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath)
720
- ?? (useLatest ? await findNewestFile(logsRoot, [".xml"]) : undefined);
721
- const logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath)
722
- ?? (useLatest ? await findNewestFile(logsRoot, [".log", ".txt"]) : undefined);
743
+ let testResultsPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.testResultsPath);
744
+ let logFilePath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.logFilePath);
723
745
  const normalizedResultPath = resolveArtifactPath(ctx.cwd, candidate.projectRoot, params.normalizedResultPath)
724
746
  ?? (useLatest ? await findNewestFile(logsRoot, [".json"]) : undefined);
747
+ if (useLatest && !normalizedResultPath) {
748
+ testResultsPath = await findNewestFile(logsRoot, [".xml"]);
749
+ if (!testResultsPath) logFilePath = await findNewestFile(logsRoot, [".log", ".txt"]);
750
+ }
725
751
  let normalized: NormalizedUnityTestResult | undefined;
726
752
  const evidenceErrors: string[] = [];
727
753
  const evidenceWarnings: string[] = [];
@@ -729,6 +755,11 @@ async function buildArtifactInspectionReport(
729
755
  try {
730
756
  if ((await stat(normalizedResultPath)).size > UNITY_TEST_MAX_ARTIFACT_BYTES) throw new Error("Normalized artifact exceeds its size limit.");
731
757
  normalized = validateNormalizedUnityTestArtifact(JSON.parse(await readFile(normalizedResultPath, "utf8")));
758
+ // Stored backend links, not unrelated directory recency, are the only automatic companions.
759
+ if (useLatest) {
760
+ if (normalized.backendArtifacts?.nunit) testResultsPath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.nunit);
761
+ if (normalized.backendArtifacts?.log) logFilePath = await resolveLinkedArtifact(candidate.projectRoot, normalized.backendArtifacts.log);
762
+ }
732
763
  } catch (error) {
733
764
  evidenceErrors.push(`Normalized test result could not be loaded/validated: ${normalizedResultPath}: ${error instanceof Error ? error.message : String(error)}`);
734
765
  }
@@ -759,7 +790,13 @@ async function buildArtifactInspectionReport(
759
790
  }
760
791
  if ((normalized.outcome === "passed" || normalized.outcome === "passed_with_flakes") && parsedTestResults.failedTests.length > 0) evidenceErrors.push("Conflicting normalized/XML evidence: XML contains failed tests.");
761
792
  const linkedXml = normalized.backendArtifacts?.nunit;
762
- if (linkedXml && resolve(candidate.projectRoot, linkedXml) !== resolve(testResultsPath!)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
793
+ if (linkedXml && testResultsPath) {
794
+ try {
795
+ if (await resolveLinkedArtifact(candidate.projectRoot, linkedXml) !== await realpath(testResultsPath)) evidenceErrors.push("Conflicting artifact identity: selected XML is not the normalized artifact's nunit path.");
796
+ } catch (error) {
797
+ evidenceErrors.push(`Normalized NUnit link could not be resolved: ${linkedXml}: ${error instanceof Error ? error.message : String(error)}`);
798
+ }
799
+ }
763
800
  if (!linkedXml) {
764
801
  evidenceWarnings.push("Normalized JSON and XML have no shared run identity; matching counts alone do not correlate these files.");
765
802
  testOutcome = "uncertain";
@@ -1220,7 +1257,25 @@ async function runUnifiedUnityTests(
1220
1257
  }
1221
1258
  const formats = request.reportFormats ?? defaultUnityTestReportFormats(route);
1222
1259
  if (route === "connected") {
1223
- const result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1260
+ let result;
1261
+ try {
1262
+ result = await runUnityPipelineTests({ projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), testPlatform: request.testPlatform, testFilter: request.testFilters[0], testCategory: request.testCategories[0], timeoutSeconds: request.timeoutSeconds, allowAutonomousExitPlayMode }, createPipelineDependencies(pi), { signal, onUpdate: message => onUpdate?.({ content: [{ type: "text", text: message }] }) });
1263
+ } catch (error) {
1264
+ if (!(error instanceof UnityPipelineTerminalTestEvidenceError)) throw error;
1265
+ const outcome = error.evidence.outcome;
1266
+ const normalized: NormalizedUnityTestResult = {
1267
+ schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
1268
+ selection: { testFilters: request.testFilters, testCategories: request.testCategories },
1269
+ durationSeconds: error.evidence.elapsedSeconds, outcome, summary: {}, tests: error.evidence.testRecords,
1270
+ diagnostics: [error.evidence.reason, ...error.evidence.observations, ...error.evidence.warnings].slice(0, 8),
1271
+ };
1272
+ let artifactPath: string;
1273
+ try { artifactPath = await writeNormalizedUnityTestArtifact(candidate.projectRoot, normalized); } catch (persistenceError) {
1274
+ throw new Error(`${error.evidence.reason} Durable terminal evidence could not be persisted: ${persistenceError instanceof Error ? persistenceError.message : String(persistenceError)}`);
1275
+ }
1276
+ const text = `${compactUnityTestSummary(normalized)}\nRoute: connected Pipeline. Terminal evidence was incomplete or non-passing; no retry, fallback, or replay was performed. Normalized artifact: ${artifactPath}`;
1277
+ return { content: [{ type: "text", text }], details: { mode: "tests", projectRoot: candidate.projectRoot, unityVersion: await requireManualUnityVersion(candidate), editorPath: "", status: "failed", testResult: { ...normalized, tests: [] }, artifactPath, route } };
1278
+ }
1224
1279
  const counts = result.details.counts!;
1225
1280
  const normalized: NormalizedUnityTestResult = {
1226
1281
  schemaVersion: 1, source: "pipeline", platform: request.testPlatform,
@@ -1294,9 +1349,9 @@ function renderUnityPipelineResult(result: any, options: { expanded: boolean; is
1294
1349
  const counts = pipeline.counts;
1295
1350
  const passed = counts?.passed === undefined || counts?.total === undefined ? "tests completed" : `${counts.passed}/${counts.total} passed`;
1296
1351
  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";
1352
+ } else if (details.mode === "pipeline_eval" || details.mode === "pipeline_inspection" || details.mode === "pipeline_run_script") {
1353
+ const output = details.mode === "pipeline_eval" ? details.pipelineEval : details.mode === "pipeline_run_script" ? details.pipelineRunScript : details.pipelineInspection;
1354
+ const label = details.mode === "pipeline_eval" ? "Unity Pipeline Eval" : details.mode === "pipeline_run_script" ? "Unity Pipeline Run Script" : "Unity Pipeline Inspection";
1300
1355
  const summary = output?.outcome === "dispatched" ? output.output || "(no bounded output returned)" : output?.message || primaryText;
1301
1356
  text = `${icon} ${theme.fg("toolTitle", theme.bold(label))}\n ${theme.fg("toolOutput", compactUnityRendererValue(summary, 240))}`;
1302
1357
  } else {
@@ -1337,7 +1392,9 @@ function renderUnityToolResult(result: any, expanded: boolean, theme: any): Text
1337
1392
  ? "Unity Pipeline Inspection"
1338
1393
  : details.mode === "pipeline_eval"
1339
1394
  ? "Unity Pipeline Eval"
1340
- : details.mode === "pipeline"
1395
+ : details.mode === "pipeline_run_script"
1396
+ ? "Unity Pipeline Run Script"
1397
+ : details.mode === "pipeline"
1341
1398
  ? "Unity Pipeline"
1342
1399
  : getBatchmodeVariantLabel(details.args);
1343
1400
  const projectLabel = details.projectRoot ?? "(unknown project)";
@@ -1391,7 +1448,9 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1391
1448
  pi.on("tool_result", (event) => {
1392
1449
  const details = event.details as UnityToolDetails | undefined;
1393
1450
  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")) {
1451
+ || (event.toolName === "unity_pipeline_inspect" && details?.mode === "pipeline_inspection" && details.pipelineInspection?.outcome === "rejected")
1452
+ || (event.toolName === "unity_pipeline_run_script" && details?.mode === "pipeline_run_script" && details.pipelineRunScript?.outcome === "rejected")
1453
+ || (event.toolName === "unity_run_tests" && details?.mode === "tests" && details.testResult?.outcome !== "passed" && details.testResult?.outcome !== "passed_with_flakes" && details.testResult?.outcome !== "empty_selection")) {
1395
1454
  return { isError: true };
1396
1455
  }
1397
1456
  });
@@ -1654,12 +1713,13 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1654
1713
  pi.registerTool({
1655
1714
  name: "unity_pipeline_eval",
1656
1715
  label: "Unity Pipeline Eval",
1657
- description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor.",
1716
+ description: "Execute one bounded C# snippet through advertised eval in an already-open exact Unity Pipeline Editor. timeoutSeconds bounds pi-unity and Unity CLI waits; optional handlerTimeoutMilliseconds is forwarded only through a verified Pipeline raw-argv timeout contract and cannot cancel code already started on Unity's main thread.",
1658
1717
  promptSnippet: "Query or operate on an already-open exact Unity project through Pipeline's Roslyn C# REPL.",
1659
1718
  promptGuidelines: [
1660
1719
  "Use unity_pipeline_eval for project-specific properties, APIs, and operations that advertised typed commands do not cover. It revalidates exact-copy identity and advertised eval immediately before dispatch.",
1661
1720
  "Pipeline eval compiles arbitrary C# with Roslyn on the Editor main thread. Include an explicit return value for observable evidence; normal property reads and local-variable snippets are supported.",
1662
1721
  "Eval is not statically read-only. Follow user intent and project guidance, and obtain explicit authorization before lifecycle, persistent-setting, destructive, asset, scene-save, package, build, or test mutations.",
1722
+ "timeoutSeconds bounds the host and Unity CLI wait. When supplied, handlerTimeoutMilliseconds bounds only the verified Pipeline dispatcher wait; a shorter host deadline may still win, and a server wait expiry cannot cancel code already started on Unity's main thread.",
1663
1723
  "A rejected, malformed, failing, or timed-out eval is not success; do not silently retry it through another route.",
1664
1724
  ],
1665
1725
  parameters: PIPELINE_EVAL_PARAMS,
@@ -1672,6 +1732,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1672
1732
  unityVersion: await requireManualUnityVersion(candidate),
1673
1733
  command: "eval",
1674
1734
  evalSnippet: params.code,
1735
+ handlerTimeoutMilliseconds: params.handlerTimeoutMilliseconds,
1675
1736
  }, {
1676
1737
  execute: createPlanningUnityCliExecutor(pi),
1677
1738
  signal,
@@ -1701,6 +1762,38 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1701
1762
  },
1702
1763
  });
1703
1764
 
1765
+ pi.registerTool({
1766
+ name: "unity_pipeline_run_script",
1767
+ label: "Unity Pipeline Run Script",
1768
+ description: "Compile one existing project C# file in memory and invoke its advertised static entry point through Pipeline 0.6 run_script.",
1769
+ promptSnippet: "Run one explicitly requested existing C# builder script through an already-open exact Unity Pipeline Editor.",
1770
+ promptGuidelines: [
1771
+ "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.",
1772
+ "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.",
1773
+ "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.",
1774
+ ],
1775
+ parameters: PIPELINE_RUN_SCRIPT_PARAMS,
1776
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1777
+ throwIfAborted(signal);
1778
+ const { candidate } = await resolveProjectCandidate(ctx, params.path);
1779
+ const file = isAbsolute(params.file) ? params.file : resolve(candidate.projectRoot, params.file);
1780
+ const result = await dispatchUnityPipelineRunScript({
1781
+ projectRoot: candidate.projectRoot,
1782
+ unityVersion: await requireManualUnityVersion(candidate),
1783
+ file,
1784
+ entry: params.entry,
1785
+ args: params.args,
1786
+ dryRun: params.dryRun,
1787
+ }, { execute: createPlanningUnityCliExecutor(pi), signal, timeout: (params.timeoutSeconds ?? 30) * 1000 });
1788
+ const text = result.outcome === "dispatched"
1789
+ ? `Unity Pipeline run_script ${params.dryRun ? "compile-only completed" : "completed"}.\n${result.output || "(no bounded output returned)"}`
1790
+ : `Unity Pipeline run_script rejected: ${result.code}\n${result.message}`;
1791
+ 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 };
1792
+ },
1793
+ renderCall(args, theme, context) { return renderUnityPipelineCall("unity_pipeline_run_script", args, theme, context); },
1794
+ renderResult(result, options, theme, context) { return renderUnityPipelineResult(result, options, theme, context); },
1795
+ });
1796
+
1704
1797
  pi.registerTool({
1705
1798
  name: "unity_pipeline_inspect",
1706
1799
  label: "Unity Pipeline Inspect",
@@ -1759,7 +1852,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1759
1852
  "Use unity_inspect_artifacts after Unity failures when existing -testResults or -logFile artifacts need concise parsing without another Unity launch.",
1760
1853
  "Prefer unity_inspect_artifacts over ad hoc bash parsing of Unity XML/log files when paths are known or Logs/ contains recent artifacts.",
1761
1854
  "unity_inspect_artifacts does not launch Unity and is safe to use even when the Unity project is busy.",
1762
- "Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. Latest files are not current-run identity.",
1855
+ "Inspect details.testOutcome, not inspection status, for test success. Passing evidence needs consistent positive passing counts; missing explicit paths and conflicting artifacts fail inspection. With all paths omitted, latest selection uses one JSON and its contained declared links, otherwise XML alone then log context; explicit paths disable selection/link expansion. Latest files are not current-run identity.",
1763
1856
  ],
1764
1857
  parameters: INSPECT_ARTIFACTS_PARAMS,
1765
1858
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -18,7 +18,7 @@
18
18
  ]
19
19
  },
20
20
  "scripts": {
21
- "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/unity-package-validation.test.ts",
21
+ "test": "tsx tests/unity-core.test.ts && tsx tests/unity-launch.test.ts && tsx tests/unity-pipeline.test.ts && tsx tests/unity-processes.test.ts && tsx tests/pi-unity-settings.test.ts && tsx tests/unity-projects.test.ts && tsx tests/unity-guidance-audit.test.ts && tsx tests/unity-batchmode.test.ts && tsx tests/unity-test-batch.test.ts && tsx tests/unity-tests.test.ts && tsx tests/unity-cli.test.ts && tsx tests/unity-project-lock.test.ts && tsx tests/unity-artifact-profile.test.ts && tsx tests/unity-file-discovery-filter.test.ts && tsx tests/unity-registration.test.ts && tsx tests/unity-optional-integrations.test.ts && tsx tests/release-registry-preflight.test.ts && tsx tests/unity-package-validation.test.ts",
22
22
  "eval:guidance-skill": "tsx evals/auditing-unity-agent-guidance/run-eval.ts"
23
23
  },
24
24
  "dependencies": {
@@ -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 bounds pi-unity/Unity CLI waits. Optional `handlerTimeoutMilliseconds` (1–86,400,000) is forwarded only when the exact reachable Pipeline advertises raw argv and the verified eval `code`/integer-`timeout` signature; it bounds Pipeline's dispatcher wait, while a shorter host wait can still win. A dispatcher expiry cannot cancel code already started on Unity's main thread, so the effect remains uncertain and no retry or fallback is allowed. 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.
@@ -9,6 +9,16 @@ const strings = (value: unknown): value is string[] => Array.isArray(value) && v
9
9
  const relativeId = (value: unknown): value is string => typeof value === "string" && !!value.trim()
10
10
  && !isAbsolute(value) && !/^(?:[A-Za-z]:|[\\/])/.test(value) && !value.split(/[\\/]/).includes("..") && !/\0/.test(value);
11
11
 
12
+ /** Shared count/retained-record invariant for durable validation and Pipeline pass acceptance. */
13
+ export function hasConsistentUnityTestCounts(summary: Record<string, unknown>, tests: Array<{ status: string }>): boolean {
14
+ const total = summary.total; const passed = summary.passed; const failed = summary.failed;
15
+ const skipped = summary.skipped; const inconclusive = summary.inconclusive;
16
+ if (![total, passed, failed, skipped, inconclusive].every(value => value === undefined || count(value))) return false;
17
+ if (count(total) && [passed, failed, skipped, inconclusive].reduce<number>((sum, value) => sum + (count(value) ? value : 0), 0) > total) return false;
18
+ if (count(total) && tests.length > total) return false;
19
+ return [["passed", passed], ["failed", failed], ["skipped", skipped], ["inconclusive", inconclusive]].every(([status, limit]) => !count(limit) || tests.filter(test => status === "passed" ? /^(?:passed|success)$/i.test(test.status) : test.status.toLowerCase() === status).length <= limit);
20
+ }
21
+
12
22
  /** Read the durable schema, not a transport response. Missing optional counts remain unknown.
13
23
  * Test records may be bounded or absent: never require tests.length === summary.total.
14
24
  */
@@ -25,10 +35,7 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
25
35
  for (const key of ["total", "passed", "failed", "skipped", "inconclusive"]) {
26
36
  if (summary[key] !== undefined && !count(summary[key])) invalid(`summary.${key} must be a non-negative integer`);
27
37
  }
28
- if (count(summary.total)) {
29
- const accounted = [summary.passed, summary.failed, summary.skipped, summary.inconclusive].reduce<number>((sum, item) => sum + (count(item) ? item : 0), 0);
30
- if (accounted > summary.total) invalid("summary counts exceed total");
31
- }
38
+ if (!hasConsistentUnityTestCounts(summary, [])) invalid("summary counts are inconsistent");
32
39
  if (!Array.isArray(result.tests)) invalid("tests must be an array");
33
40
  const tests = result.tests as unknown[];
34
41
  for (const test of tests) {
@@ -38,14 +45,11 @@ export function validateNormalizedUnityTestArtifact(value: unknown): NormalizedU
38
45
  if (item.durationSeconds !== undefined && !nonnegative(item.durationSeconds)) invalid("test durationSeconds must be non-negative");
39
46
  if (item.attempts !== undefined && (!count(item.attempts) || item.attempts < 1)) invalid("test attempts must be positive");
40
47
  }
41
- if (count(summary.total) && tests.length > summary.total) invalid("test records exceed total");
48
+ if (!hasConsistentUnityTestCounts(summary, tests as Array<{ status: string }>)) invalid("test records conflict with summary counts");
42
49
  const typed = result as unknown as NormalizedUnityTestResult;
43
- for (const [status, key] of [["passed", "passed"], ["failed", "failed"], ["skipped", "skipped"], ["inconclusive", "inconclusive"]] as const) {
44
- const observed = typed.tests.filter(test => test.status.toLowerCase() === status).length;
45
- if (count(summary[key]) && observed > summary[key]) invalid(`test records conflict with summary.${key}`);
46
- }
47
50
  if (result.projectRelativeId !== undefined && !relativeId(result.projectRelativeId)) invalid("projectRelativeId must be project-relative");
48
51
  if (result.backendArtifacts !== undefined && (!record(result.backendArtifacts) || !Object.values(result.backendArtifacts).every(relativeId))) invalid("backendArtifacts must contain project-relative paths");
52
+ if (result.diagnostics !== undefined && (!Array.isArray(result.diagnostics) || result.diagnostics.length > 8 || !result.diagnostics.every(item => typeof item === "string" && !!item.trim() && item.length <= 1_000))) invalid("diagnostics must contain at most eight bounded strings");
49
53
  for (const key of ["startedAt", "completedAt"]) if (result[key] !== undefined && (typeof result[key] !== "string" || !Number.isFinite(Date.parse(result[key] as string)))) invalid(`${key} must be a timestamp`);
50
54
  if (typed.startedAt && typed.completedAt && Date.parse(typed.completedAt) < Date.parse(typed.startedAt)) invalid("completion precedes start");
51
55
  if (result.durationSeconds !== undefined && !nonnegative(result.durationSeconds)) invalid("durationSeconds must be non-negative");
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
 
@@ -46,6 +46,13 @@ export type UnityCliPipelineInstance = {
46
46
 
47
47
  export type UnityCliDiscoveryState = "not_attempted" | "available" | "absent" | "timeout" | "unavailable";
48
48
 
49
+ export type UnityCliCommandParameter = {
50
+ name: string;
51
+ type: string;
52
+ required: boolean;
53
+ defaultValue?: unknown;
54
+ };
55
+
49
56
  export type UnityCliProjectCapabilities = {
50
57
  cliAvailable: boolean;
51
58
  cliVersion?: string;
@@ -56,6 +63,12 @@ export type UnityCliProjectCapabilities = {
56
63
  advertisedCommands: string[];
57
64
  advertisedCommandCount: number;
58
65
  advertisedCommandsTruncated: boolean;
66
+ /** Display-oriented command descriptors. They are never capability evidence. */
67
+ advertisedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
68
+ /** Complete, bounded, unambiguous descriptors eligible for exact capability gates. */
69
+ verifiedCommandParameters?: Record<string, readonly UnityCliCommandParameter[]>;
70
+ /** True only when the exact live Pipeline descriptor advertises raw argv support. */
71
+ pipelineSupportsExecArgv?: boolean;
59
72
  commandDiscoveryAttempted: boolean;
60
73
  commandDiscoverySucceeded: boolean;
61
74
  latestPipelineVersion?: string;
@@ -265,10 +278,10 @@ export function parseUnityCliStatusOutput(output: string, projectRoot: string):
265
278
 
266
279
  export async function listRunningUnityCliEditorsForProject(
267
280
  projectRoot: string,
268
- options: { cliCommand?: string; timeout?: number } = {},
281
+ options: { cliCommand?: string; timeout?: number; execute?: UnityCliExecutor } = {},
269
282
  ): Promise<{ processes: RunningUnityProcess[]; warning?: string }> {
270
283
  const command = resolveUnityCliCommand(options);
271
- const result = await execFileCollect(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
284
+ const result = await (options.execute ?? execFileCollect)(command, ["--format", "json", "--no-banner", "--non-interactive", "status", "--project", projectRoot], {
272
285
  timeout: options.timeout ?? 5000,
273
286
  });
274
287
 
@@ -277,11 +290,14 @@ export async function listRunningUnityCliEditorsForProject(
277
290
  }
278
291
 
279
292
  const processes = parseUnityCliStatusOutput(result.stdout, projectRoot);
280
- if (processes.length > 0) {
281
- return { processes };
293
+ const payload = parseJsonObject(result.stdout);
294
+ const statusWarnings = envelopeMessages(payload, "warnings");
295
+ if (statusWarnings.length > 0) {
296
+ return { processes, warning: `Unity CLI status response is incomplete; process absence is uncertain: ${statusWarnings.join("; ")}` };
282
297
  }
298
+ if (processes.length > 0) return { processes };
299
+
283
300
 
284
- const payload = parseJsonObject(result.stdout);
285
301
  const errors = Array.isArray(payload?.errors) ? payload.errors : [];
286
302
  const onlyNoInstances = errors.some((entry) => getRecord(entry)?.code === "STATUS_NO_INSTANCES");
287
303
  if (result.error && !onlyNoInstances) {
@@ -343,10 +359,16 @@ export function parseUnityCliPipelineListOutput(output: string, projectRoot: str
343
359
  type UnityCliCommandCatalog = {
344
360
  valid: boolean;
345
361
  commands: string[];
362
+ parametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
363
+ verifiedParametersByCommand: Record<string, readonly UnityCliCommandParameter[]>;
346
364
  total: number;
347
365
  truncated: boolean;
348
366
  };
349
367
 
368
+ function isBoundedDescriptorString(value: unknown): value is string {
369
+ return typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value);
370
+ }
371
+
350
372
  function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
351
373
  const payload = parseJsonObject(output);
352
374
  const data = getRecord(payload?.data);
@@ -363,21 +385,53 @@ function parseUnityCliCommandCatalog(output: string): UnityCliCommandCatalog {
363
385
  candidates.push(...value);
364
386
  }
365
387
  }
366
- const names = candidates.flatMap((entry): string[] => {
367
- const rawName = typeof entry === "string"
368
- ? entry
369
- : optionalString(getRecord(entry)?.name, getRecord(entry)?.command, getRecord(entry)?.id);
370
- if (!rawName) return [];
388
+
389
+ const parametersByCommand: Record<string, readonly UnityCliCommandParameter[]> = {};
390
+ const verifiedCandidates = new Map<string, Array<readonly UnityCliCommandParameter[] | undefined>>();
391
+ const names: string[] = [];
392
+ for (const entry of candidates) {
393
+ const record = getRecord(entry);
394
+ const rawName = typeof entry === "string" ? entry : optionalString(record?.name, record?.command, record?.id);
395
+ if (!isBoundedDescriptorString(rawName)) continue;
371
396
  const name = rawName.trim();
372
- if (!name || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) return [];
373
- return [name];
374
- });
397
+ if (!isBoundedDescriptorString(name)) continue;
398
+ names.push(name);
399
+
400
+ // Preserve a best-effort descriptor for status display, but retain authoritative
401
+ // evidence only when every declared parameter is valid and the array was not cut.
402
+ const rawParameters = record?.parameters;
403
+ let parsed: UnityCliCommandParameter[] | undefined;
404
+ if (Array.isArray(rawParameters) && rawParameters.length <= 32) {
405
+ parsed = [];
406
+ for (const item of rawParameters) {
407
+ const parameter = getRecord(item);
408
+ const parameterName = parameter?.name;
409
+ const type = parameter?.type;
410
+ if (!isBoundedDescriptorString(parameterName) || !isBoundedDescriptorString(type) || typeof parameter?.required !== "boolean") {
411
+ parsed = undefined;
412
+ break;
413
+ }
414
+ parsed.push({ name: parameterName, type, required: parameter.required, ...(Object.prototype.hasOwnProperty.call(parameter, "defaultValue") ? { defaultValue: parameter.defaultValue } : {}) });
415
+ }
416
+ }
417
+ if (!(name in parametersByCommand) && parsed) parametersByCommand[name] = parsed;
418
+ const entries = verifiedCandidates.get(name) ?? [];
419
+ entries.push(parsed);
420
+ verifiedCandidates.set(name, entries);
421
+ }
422
+
375
423
  const unique = [...new Set(names)].sort((left, right) => left.localeCompare(right));
424
+ const commands = unique.slice(0, 256);
425
+ const verifiedParametersByCommand = Object.fromEntries([...verifiedCandidates].flatMap(([name, descriptors]) =>
426
+ descriptors.length === 1 && descriptors[0] ? [[name, descriptors[0]]] : [],
427
+ ));
376
428
  return {
377
429
  valid: Boolean(payload?.success === true && valid),
378
- commands: unique.slice(0, 256),
430
+ commands,
431
+ parametersByCommand: Object.fromEntries(commands.flatMap(name => parametersByCommand[name] ? [[name, parametersByCommand[name]]] : [])),
432
+ verifiedParametersByCommand,
379
433
  total: unique.length,
380
- truncated: unique.length > 256 || names.length < candidates.length,
434
+ truncated: unique.length > 256,
381
435
  };
382
436
  }
383
437
 
@@ -425,19 +479,40 @@ export async function readDeclaredUnityPipelineVersion(projectRoot: string): Pro
425
479
  return optionalString(manifestDependencies?.["com.unity.pipeline"]);
426
480
  }
427
481
 
482
+ /** Read only the public capability names; the descriptor's authentication token is never retained or surfaced. */
483
+ async function readPipelineDescriptorCapabilities(projectRoot: string): Promise<string[] | undefined> {
484
+ const descriptor = await readJsonFile(join(projectRoot, "Library", "Pipeline", ".unity-pipeline-port"));
485
+ if (!descriptor) return undefined;
486
+ const values = Array.isArray(descriptor.capabilities) ? descriptor.capabilities : [];
487
+ return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0 && value.length <= 120 && !/[\u0000-\u001f\u007f]/.test(value)))];
488
+ }
489
+
428
490
  export function isUnityCliTimeout(result: Pick<UnityCliExecResult, "error">): boolean {
429
491
  const error = result.error as (NodeJS.ErrnoException & { killed?: boolean }) | undefined;
430
492
  return error?.code === "ETIMEDOUT" || error?.killed === true || error?.signal === "SIGTERM";
431
493
  }
432
494
 
495
+ const UNITY_CLI_MAX_DIAGNOSTICS = 8;
496
+ function envelopeMessages(payload: Record<string, unknown> | null, fieldName: "errors" | "warnings" | "info"): string[] {
497
+ const entries = Array.isArray(payload?.[fieldName]) ? payload[fieldName] : [];
498
+ return entries.slice(0, UNITY_CLI_MAX_DIAGNOSTICS).flatMap((entry): string[] => {
499
+ const item = getRecord(entry);
500
+ const message = optionalString(item?.message, item?.detail, typeof entry === "string" ? entry : undefined);
501
+ return message ? [redactUnityPlanningOutput(summarizeUnityCliText(message, 1_000, 10))] : [];
502
+ });
503
+ }
504
+
505
+ /** Warnings make discovery incomplete; informational descriptor/envelope notes do not. */
506
+ function cliEnvelopeDiagnostics(payload: Record<string, unknown> | null): string[] {
507
+ return envelopeMessages(payload, "warnings").map(message => `warning: ${message}`);
508
+ }
509
+ function cliEnvelopeInfo(payload: Record<string, unknown> | null): string[] {
510
+ return envelopeMessages(payload, "info").map(message => `info: ${message}`);
511
+ }
512
+
433
513
  function cliFailureMessage(result: UnityCliExecResult): string | undefined {
434
514
  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);
515
+ const message = envelopeMessages(payload, "errors")[0] ?? (result.stderr.trim() || result.error?.message);
441
516
  return message ? summarizeUnityCliText(message) : undefined;
442
517
  }
443
518
 
@@ -467,7 +542,9 @@ export async function inspectUnityCliProjectCapabilities(
467
542
  const command = resolveUnityCliCommand(options);
468
543
  const versionTimeout = options.timeout ?? UNITY_CLI_VERSION_TIMEOUT_MS;
469
544
  const discoveryTimeout = options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS;
470
- const versionResult = await execute(command, ["--version"], { timeout: versionTimeout, signal: options.signal });
545
+ // Explicitly select the supported human formatter: inherited UNITY_FORMAT must not turn
546
+ // --version into JSON (or another representation) and corrupt capability reporting.
547
+ const versionResult = await execute(command, ["--format", "human", "--version"], { timeout: versionTimeout, signal: options.signal });
471
548
  if (versionResult.error && (versionResult.error as NodeJS.ErrnoException).code === "ENOENT") return result;
472
549
  if (versionResult.error) {
473
550
  result.warnings.push(`Unity CLI version probe ${isUnityCliTimeout(versionResult) ? "timed out" : "failed"}: ${cliFailureMessage(versionResult) ?? "unknown error"}`);
@@ -479,14 +556,22 @@ export async function inspectUnityCliProjectCapabilities(
479
556
  const pipelineResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "pipeline", "list"], { timeout: discoveryTimeout, signal: options.signal });
480
557
  const pipelinePayload = parseJsonObject(pipelineResult.stdout);
481
558
  const pipelineData = getRecord(pipelinePayload?.data);
482
- if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances)) {
559
+ const pipelineDiagnostics = cliEnvelopeDiagnostics(pipelinePayload);
560
+ result.warnings.push(...cliEnvelopeInfo(pipelinePayload));
561
+ if (pipelineResult.error || pipelinePayload?.success !== true || !Array.isArray(pipelineData?.instances) || pipelineDiagnostics.length > 0) {
483
562
  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"}`);
563
+ const diagnostic = pipelineDiagnostics.join("; ") || cliFailureMessage(pipelineResult) || "malformed or unsupported JSON response";
564
+ 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}`);
565
+ // Retain any known positive exact-copy instance descriptors, but never use this
566
+ // incomplete response as a safe launch or connected-dispatch signal.
567
+ result.matchingInstances = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot).instances;
485
568
  return result;
486
569
  }
487
570
  result.pipelineDiscovery = "available";
488
571
  const pipeline = parseUnityCliPipelineListOutput(pipelineResult.stdout, projectRoot);
489
572
  result.matchingInstances = pipeline.instances;
573
+ const descriptorCapabilities = await readPipelineDescriptorCapabilities(projectRoot);
574
+ result.pipelineSupportsExecArgv = descriptorCapabilities?.includes("exec.argv") === true;
490
575
  result.latestPipelineVersion = pipeline.latestVersion;
491
576
  if (pipeline.instances.length === 0) {
492
577
  result.pipelineDiscovery = "absent";
@@ -498,15 +583,29 @@ export async function inspectUnityCliProjectCapabilities(
498
583
  }
499
584
 
500
585
  result.commandDiscoveryAttempted = true;
501
- const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "list", "--project-path", projectRoot], { timeout: discoveryTimeout, signal: options.signal });
586
+ // `unity list` normalizes parameter types/defaults, while `unity command` with no command
587
+ // returns the live Pipeline catalog descriptor needed for exact capability-gated forwarding.
588
+ const listResult = await execute(command, ["--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot, "--detail", "full"], { timeout: discoveryTimeout, signal: options.signal });
502
589
  const catalog = parseUnityCliCommandCatalog(listResult.stdout);
503
- if (listResult.error || !catalog.valid) {
590
+ const listPayload = parseJsonObject(listResult.stdout);
591
+ const commandDiagnostics = cliEnvelopeDiagnostics(listPayload);
592
+ result.warnings.push(...cliEnvelopeInfo(listPayload));
593
+ if (listResult.error || !catalog.valid || commandDiagnostics.length > 0) {
504
594
  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"}`);
595
+ 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"}`);
596
+ // Commands in a warning-bearing catalog are informational only, not advertised
597
+ // capability evidence. Keep descriptors for status visibility without enabling dispatch.
598
+ result.advertisedCommands = catalog.commands;
599
+ result.advertisedCommandParameters = catalog.parametersByCommand;
600
+ result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
601
+ result.advertisedCommandCount = catalog.total;
602
+ result.advertisedCommandsTruncated = catalog.truncated;
506
603
  return result;
507
604
  }
508
605
  result.commandDiscovery = "available";
509
606
  result.advertisedCommands = catalog.commands;
607
+ result.advertisedCommandParameters = catalog.parametersByCommand;
608
+ result.verifiedCommandParameters = catalog.verifiedParametersByCommand;
510
609
  result.advertisedCommandCount = catalog.total;
511
610
  result.advertisedCommandsTruncated = catalog.truncated;
512
611
  result.commandDiscoverySucceeded = true;
@@ -522,6 +621,7 @@ export const UNITY_PLANNING_READ_COMMANDS = Object.freeze([
522
621
  "get_authoring_root",
523
622
  "get_build_settings",
524
623
  "get_player_settings",
624
+ "get_runtime_pipeline_settings",
525
625
  "get_scene_hierarchy",
526
626
  "editor_status",
527
627
  "list_open_scenes",
@@ -536,12 +636,29 @@ export type UnityPlanningInspectionRequest = {
536
636
  args?: string[];
537
637
  /** A bounded C# snippet for advertised eval. Pipeline compiles it with Roslyn on the Editor main thread. */
538
638
  evalSnippet?: string;
639
+ /** Verified Pipeline eval dispatcher wait in milliseconds; distinct from the CLI/host wait. */
640
+ handlerTimeoutMilliseconds?: number;
539
641
  };
540
642
 
541
643
  export type UnityPlanningInspectionResult =
542
644
  | { outcome: "dispatched"; command: string; output: string; truncated: boolean }
543
645
  | { outcome: "rejected"; code: string; message: string };
544
646
 
647
+ export type UnityPipelineRunScriptRequest = {
648
+ projectRoot: string;
649
+ unityVersion: string;
650
+ file: string;
651
+ entry?: string;
652
+ args?: unknown[];
653
+ dryRun?: boolean;
654
+ };
655
+
656
+ /** Attach bounded native discovery guidance without changing the readiness decision. */
657
+ export function unityCapabilityDiagnosticSuffix(capabilities: UnityCliProjectCapabilities): string {
658
+ const message = summarizeUnityCliText(redactUnityPlanningOutput(capabilities.warnings.slice(0, 8).join("; ")), 1_000, 10);
659
+ return message ? ` ${message}` : "";
660
+ }
661
+
545
662
  function planningInspectionReadiness(capabilities: UnityCliProjectCapabilities): string | undefined {
546
663
  if (!capabilities.cliAvailable) return "unity_cli_unavailable";
547
664
  if (capabilities.pipelineDiscovery !== "available") return `pipeline_${capabilities.pipelineDiscovery}`;
@@ -556,12 +673,46 @@ function caseInsensitiveField(record: Record<string, unknown>, name: string): un
556
673
  return entry?.[1];
557
674
  }
558
675
 
676
+ function runScriptCommandFailure(output: string): "malformed" | "failure" | undefined {
677
+ const envelope = parseJsonObject(output);
678
+ if (!envelope || caseInsensitiveField(envelope, "success") !== true) return envelope ? "failure" : "malformed";
679
+ const wrapper = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
680
+ if (caseInsensitiveField(wrapper, "success") === false) return "failure";
681
+ let response: unknown = caseInsensitiveField(wrapper, "result");
682
+ // Legacy wrapper puts the documented RunScriptResponse in data.result; compact
683
+ // transport puts it in result. A bare success envelope has no command evidence.
684
+ if (typeof response === "string") { try { response = JSON.parse(response); } catch { return "malformed"; } }
685
+ const result = getRecord(response);
686
+ if (!result || !Object.prototype.hasOwnProperty.call(result, "diagnostics") || !Array.isArray(caseInsensitiveField(result, "diagnostics"))) return "malformed";
687
+ if (caseInsensitiveField(result, "success") === false || caseInsensitiveField(result, "failed") === true) return "failure";
688
+ const diagnostics = caseInsensitiveField(result, "diagnostics") as unknown[];
689
+ if (diagnostics.some(item => {
690
+ const diagnostic = getRecord(item);
691
+ return String(caseInsensitiveField(diagnostic ?? {}, "severity") ?? "").toLowerCase() === "error";
692
+ })) return "failure";
693
+ return undefined;
694
+ }
695
+
696
+ function hasVerifiedEvalTimeoutContract(capabilities: UnityCliProjectCapabilities): boolean {
697
+ const parameters = capabilities.verifiedCommandParameters?.eval;
698
+ return capabilities.pipelineSupportsExecArgv === true
699
+ && Array.isArray(parameters)
700
+ && parameters.length === 2
701
+ && parameters[0]?.name === "code"
702
+ && parameters[0]?.type === "String"
703
+ && parameters[0]?.required === true
704
+ && parameters[1]?.name === "timeout"
705
+ && parameters[1]?.type === "Int32"
706
+ && parameters[1]?.required === false
707
+ && parameters[1]?.defaultValue === 5000;
708
+ }
709
+
559
710
  function connectedCommandFailure(output: string, isEval: boolean): "malformed" | "failure" | undefined {
560
711
  const envelope = parseJsonObject(output);
561
712
  if (!envelope) return "malformed";
562
713
  if (caseInsensitiveField(envelope, "success") !== true) return "failure";
563
- const data = getRecord(caseInsensitiveField(envelope, "data"));
564
- if (!data) return "malformed";
714
+ // Pipeline 0.6 compact responses omit the CLI wrapper's data field.
715
+ const data = getRecord(caseInsensitiveField(envelope, "data")) ?? envelope;
565
716
  if (caseInsensitiveField(data, "success") === false) return "failure";
566
717
  if (!isEval) return undefined;
567
718
 
@@ -610,7 +761,7 @@ export async function dispatchUnityPlanningInspection(
610
761
  }));
611
762
  const initial = await inspect(projectRoot, request.unityVersion);
612
763
  const initialFailure = planningInspectionReadiness(initial);
613
- if (initialFailure) return { outcome: "rejected", code: initialFailure, message: "Exact-copy Pipeline planning inspection is not established." };
764
+ if (initialFailure) return { outcome: "rejected", code: initialFailure, message: `Exact-copy Pipeline planning inspection is not established.${unityCapabilityDiagnosticSuffix(initial)}` };
614
765
 
615
766
  const isEval = request.command === "eval";
616
767
  const hasBoundedArgs = (request.args?.length ?? 0) <= 12
@@ -623,6 +774,12 @@ export async function dispatchUnityPlanningInspection(
623
774
  if (request.args?.length || !snippet || snippet.length > UNITY_PIPELINE_EVAL_MAX_CHARS || /[\u0000]/.test(snippet)) {
624
775
  return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval requires one non-empty bounded C# snippet and no separate arguments." };
625
776
  }
777
+ if (request.handlerTimeoutMilliseconds !== undefined && (!Number.isInteger(request.handlerTimeoutMilliseconds) || request.handlerTimeoutMilliseconds < 1 || request.handlerTimeoutMilliseconds > 86_400_000)) {
778
+ return { outcome: "rejected", code: "planning_eval_invalid", message: "Eval handler timeout must be an integer from 1 to 86400000 milliseconds." };
779
+ }
780
+ if (request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(initial)) {
781
+ return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline copy does not establish raw argv support and the documented eval timeout signature; eval was not dispatched." };
782
+ }
626
783
  } else if (!UNITY_PLANNING_READ_COMMANDS.includes(request.command as typeof UNITY_PLANNING_READ_COMMANDS[number]) || (request.evalSnippet?.trim() ?? "") !== "") {
627
784
  return { outcome: "rejected", code: "planning_command_invalid", message: "Only a package-owned purpose-built inspection command may be selected here." };
628
785
  }
@@ -633,18 +790,21 @@ export async function dispatchUnityPlanningInspection(
633
790
  const refreshed = await inspect(projectRoot, request.unityVersion);
634
791
  const refreshedFailure = planningInspectionReadiness(refreshed);
635
792
  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." };
793
+ return { outcome: "rejected", code: "unity_project_identity_changed", message: `Pipeline identity changed or disconnected immediately before planning dispatch.${unityCapabilityDiagnosticSuffix(refreshed)}` };
637
794
  }
638
795
  if (!refreshed.advertisedCommands.includes(request.command)) {
639
796
  return { outcome: "rejected", code: "planning_command_unadvertised", message: "The refreshed exact Pipeline copy did not advertise the requested command." };
640
797
  }
798
+ if (isEval && request.handlerTimeoutMilliseconds !== undefined && !hasVerifiedEvalTimeoutContract(refreshed)) {
799
+ return { outcome: "rejected", code: "planning_eval_timeout_unavailable", message: "The exact Pipeline eval timeout capability changed before dispatch; eval was not dispatched." };
800
+ }
641
801
 
642
802
  const command = resolveUnityCliCommand({ cliCommand: options.cliCommand });
643
803
  const args = [
644
804
  "--format", "json", "--no-banner", "--non-interactive", "command", "--project-path", projectRoot,
645
805
  "--timeout", String(Math.max(1, Math.ceil((options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS) / 1000))),
646
806
  request.command,
647
- ...(isEval ? [request.evalSnippet!.trim()] : request.args ?? []),
807
+ ...(isEval ? [request.evalSnippet!.trim(), ...(request.handlerTimeoutMilliseconds === undefined ? [] : [String(request.handlerTimeoutMilliseconds)])] : request.args ?? []),
648
808
  ];
649
809
  const execution = await options.execute(command, args, { timeout: options.timeout ?? UNITY_CLI_DISCOVERY_TIMEOUT_MS, signal: options.signal });
650
810
  const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n");
@@ -663,6 +823,37 @@ export async function dispatchUnityPlanningInspection(
663
823
  return { outcome: "dispatched", command: request.command, output, truncated: output.length < raw.trim().length };
664
824
  }
665
825
 
826
+ /** Dispatch the documented Pipeline 0.6 ephemeral run_script form; hotpatch is deliberately not exposed. */
827
+ export async function dispatchUnityPipelineRunScript(
828
+ request: UnityPipelineRunScriptRequest,
829
+ options: { cliCommand?: string; timeout?: number; signal?: AbortSignal; execute: UnityCliExecutor; inspect?: (projectRoot: string, unityVersion: string) => Promise<UnityCliProjectCapabilities> },
830
+ ): Promise<UnityPlanningInspectionResult> {
831
+ let projectRoot: string; let file: string;
832
+ try { projectRoot = await realpath(request.projectRoot); file = await realpath(request.file); } catch {
833
+ return { outcome: "rejected", code: "run_script_path_unavailable", message: "The project root or existing script file could not be canonicalized." };
834
+ }
835
+ const relativeFile = relative(projectRoot, file);
836
+ let fileStats: Awaited<ReturnType<typeof stat>>;
837
+ try { fileStats = await stat(file); } catch { return { outcome: "rejected", code: "run_script_file_invalid", message: "run_script requires one readable existing C# file." }; }
838
+ 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." };
839
+ let serializedArgs: string;
840
+ try { serializedArgs = JSON.stringify(request.args ?? []); } catch { return { outcome: "rejected", code: "run_script_args_invalid", message: "run_script arguments must be JSON-serializable." }; }
841
+ 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." };
842
+ const inspect = options.inspect ?? ((root, version) => inspectUnityCliProjectCapabilities(root, version, { cliCommand: options.cliCommand, timeout: options.timeout, signal: options.signal, execute: options.execute }));
843
+ const initial = await inspect(projectRoot, request.unityVersion);
844
+ 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)}` };
845
+ const refreshed = await inspect(projectRoot, request.unityVersion);
846
+ 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)}` };
847
+ const timeout = Math.max(1, Math.min(options.timeout ?? 30_000, 86_400_000));
848
+ 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"] : [])];
849
+ const execution = await options.execute(resolveUnityCliCommand({ cliCommand: options.cliCommand }), args, { timeout, signal: options.signal });
850
+ const raw = [execution.stdout, execution.stderr].filter(Boolean).join("\n"); const output = summarizeUnityCliText(redactUnityPlanningOutput(raw), 4_000, 40);
851
+ 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}` : ""}` };
852
+ const failure = runScriptCommandFailure(execution.stdout);
853
+ 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}` : ""}` };
854
+ return { outcome: "dispatched", command: "run_script", output, truncated: output.length < raw.trim().length };
855
+ }
856
+
666
857
  /** Keep connected inspection output useful without returning common credential forms verbatim. */
667
858
  export function redactUnityPlanningOutput(value: string): string {
668
859
  return value
@@ -1,6 +1,7 @@
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 { hasConsistentUnityTestCounts } from "./unity-artifact-inspection";
4
+ import { redactUnityPlanningOutput, resolveUnityCliCommand, summarizeUnityCliText, unityCapabilityDiagnosticSuffix, type UnityCliExecResult, type UnityCliExecutor, type UnityCliProjectCapabilities } from "./unity-cli";
4
5
 
5
6
  /** Public limits are deliberately small enough that connected work cannot create an unbounded agent wait loop. */
6
7
  export const UNITY_PIPELINE_COMPILE_TIMEOUT_SECONDS = 180;
@@ -31,10 +32,17 @@ export type UnityPipelineOperationDetails = {
31
32
  testPlatform?: "EditMode" | "PlayMode";
32
33
  testFilter?: string;
33
34
  counts?: { total: number; passed?: number; failed: number; inconclusive?: number };
35
+ /** Bounded nonfatal Pipeline envelope guidance; never compiler-error evidence. */
36
+ warnings?: string[];
34
37
  };
35
38
  export type UnityPipelineTestRecord = { name: string; status: string; durationSeconds?: number; message?: string; stackTrace?: string };
36
39
  /** testRecords are terminal evidence for the caller's durable artifact only; do not expose them in tool details. */
37
40
  export type UnityPipelineOperationResult = { text: string; details: UnityPipelineOperationDetails; testRecords?: UnityPipelineTestRecord[] };
41
+ export type UnityPipelineTerminalTestEvidence = { state: "completed" | "failed" | "cancelled"; outcome: "uncertain" | "tests_failed" | "run_error" | "cancelled"; reason: string; elapsedSeconds: number; selection: { platform: "EditMode" | "PlayMode"; filter?: string }; correlation: Record<string, string>; observations: string[]; testRecords: UnityPipelineTestRecord[]; warnings: string[] };
42
+ /** Terminal Pipeline evidence can be durable and inspectable without being passing evidence. */
43
+ export class UnityPipelineTerminalTestEvidenceError extends Error {
44
+ constructor(readonly evidence: UnityPipelineTerminalTestEvidence) { super(evidence.reason); this.name = "UnityPipelineTerminalTestEvidenceError"; }
45
+ }
38
46
 
39
47
  type RecordValue = Record<string, unknown>;
40
48
  type ParsedEnvelope = { result: RecordValue; outerSuccess: boolean; malformed?: string };
@@ -44,6 +52,8 @@ type NormalizedTest = {
44
52
  total?: number; passed?: number; failed?: number; inconclusive?: number; failures: string[];
45
53
  correlation: Record<string, string>;
46
54
  testRecords?: UnityPipelineTestRecord[];
55
+ testFailureEstablished: boolean;
56
+ runnerError: boolean;
47
57
  };
48
58
 
49
59
  type PipelineDependencies = {
@@ -72,6 +82,39 @@ function bounded(value: string, limit = UNITY_PIPELINE_MAX_STACK_CHARS): string
72
82
  const oneLine = value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
73
83
  return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
74
84
  }
85
+ function pipelineEnvelopeWarnings(output: string): string[] {
86
+ const outer = (() => { try { return record(JSON.parse(output)); } catch { return undefined; } })();
87
+ const data = record(outer?.data);
88
+ const entries = [outer?.warnings, data?.warnings].flatMap(value => Array.isArray(value) ? value.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS) : []);
89
+ return entries.slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS).flatMap(entry => {
90
+ const item = record(entry); const message = string(field(item ?? {}, "message", "detail", "warning")) ?? string(entry);
91
+ return message ? [bounded(redactUnityPlanningOutput(message), 1_000)] : [];
92
+ });
93
+ }
94
+ function retainWarnings(warnings: string[], output: string): void {
95
+ for (const warning of pipelineEnvelopeWarnings(output)) {
96
+ if (warnings.length >= UNITY_PIPELINE_MAX_DIAGNOSTICS) break;
97
+ if (!warnings.includes(warning)) warnings.push(warning);
98
+ }
99
+ }
100
+ function warningText(warnings: string[]): string {
101
+ return warnings.length ? `\nPipeline warnings: ${warnings.join("; ")}` : "";
102
+ }
103
+ function pipelineFailureDiagnostic(response: UnityCliExecResult): string {
104
+ const raw = [response.stdout, response.stderr, response.error?.message].filter(Boolean).join("\n");
105
+ let message: string | undefined;
106
+ try {
107
+ const outer = record(JSON.parse(response.stdout)); const data = record(outer?.data);
108
+ const errors = Array.isArray(outer?.errors) ? outer?.errors : [];
109
+ const first = record(errors[0]);
110
+ const messages = [
111
+ string(field(first ?? {}, "code")), string(field(first ?? {}, "message", "detail")),
112
+ ...[data, outer].flatMap(item => ["error", "errordetails", "message"].map(key => string(field(item ?? {}, key)))),
113
+ ].filter((value): value is string => Boolean(value));
114
+ message = [...new Set(messages)].join("; ") || undefined;
115
+ } catch { /* retain bounded native raw fallback */ }
116
+ return summarizeUnityCliText(redactUnityPlanningOutput(message ?? raw), 1_000, 10);
117
+ }
75
118
  function throwIfAborted(signal?: AbortSignal): void {
76
119
  if (signal?.aborted) throw new Error("Unity Pipeline operation aborted; its Editor operation may still be running.");
77
120
  }
@@ -102,8 +145,10 @@ export function parseUnityPipelineEnvelope(output: string): ParsedEnvelope {
102
145
  let outer: RecordValue | undefined;
103
146
  try { outer = record(JSON.parse(output)); } catch { return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned malformed JSON." }; }
104
147
  if (!outer) return { result: {}, outerSuccess: false, malformed: "Unity Pipeline returned a non-object JSON envelope." };
148
+ // Pipeline 0.6 may return the compact exec envelope { success, result, warnings }
149
+ // rather than the older CLI wrapper { success, data: { result } }.
105
150
  const data = record(outer.data);
106
- const rawResult = data?.result ?? data;
151
+ const rawResult = data?.result ?? outer.result ?? data;
107
152
  if (typeof rawResult === "string") {
108
153
  try {
109
154
  const parsed = record(JSON.parse(rawResult));
@@ -148,22 +193,23 @@ function diagnostics(result: RecordValue): string[] {
148
193
  });
149
194
  return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
150
195
  }
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;
196
+ /**
197
+ * Do not retry generic busy envelopes. Pipeline 0.6 uses busy for modal dialogs as
198
+ * well as startup settling, and neither is distinguishable in the legacy CLI-shaped
199
+ * response. Retrying would blindly repeat a command blocked by a user-visible modal.
200
+ * A future retry must be wired only to a source-verified pre-dispatch discriminator.
201
+ */
202
+ export function isUnityPipelineInitialSettlingBusy(_output: string): boolean {
203
+ return false;
160
204
  }
161
205
 
162
206
  export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
163
207
  const parsed = parseUnityPipelineEnvelope(output);
164
208
  if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
165
209
  const compilerDiagnostics = diagnostics(parsed.result);
166
- const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilerDiagnostics.length > 0;
210
+ let compilationFailed = false;
211
+ walk(parsed.result, item => { if (field(item, "compilationfailed") === true) compilationFailed = true; });
212
+ const failed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || compilationFailed || compilerDiagnostics.length > 0;
167
213
  const raw = statusOf(parsed.result);
168
214
  const state = failed || raw === "failed" || raw === "error" ? "failed" : raw === "up_to_date" || raw === "uptodate" ? "up_to_date"
169
215
  : raw === "triggered" ? "triggered" : raw === "compiling" || raw === "running" ? "compiling"
@@ -193,6 +239,9 @@ function testRecords(result: RecordValue): UnityPipelineTestRecord[] {
193
239
  });
194
240
  return values.slice(0, 2_000);
195
241
  }
242
+ function isRecognizedFailedTestStatus(status: string): boolean {
243
+ return status.trim().toLowerCase() === "failed";
244
+ }
196
245
  function testFailures(result: RecordValue): string[] {
197
246
  const values: string[] = [];
198
247
  walk(result, item => {
@@ -202,7 +251,7 @@ function testFailures(result: RecordValue): string[] {
202
251
  for (const entry of entries.slice(0, 200)) {
203
252
  const test = record(entry); if (!test) continue;
204
253
  const outcome = string(field(test, "result", "status", "outcome"))?.toLowerCase();
205
- if (!outcome || /pass|success/.test(outcome)) continue;
254
+ if (!outcome || /^(?:passed|success)$/i.test(outcome)) continue;
206
255
  const name = string(field(test, "name", "fullname", "testname")) ?? "Unnamed test";
207
256
  const message = string(field(test, "message", "error", "failuremessage"));
208
257
  const stack = string(field(test, "stacktrace", "stack", "trace"));
@@ -232,19 +281,23 @@ function correlation(result: RecordValue): Record<string, string> {
232
281
  }
233
282
  export function normalizeUnityPipelineTest(output: string): NormalizedTest {
234
283
  const parsed = parseUnityPipelineEnvelope(output);
235
- if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {} };
284
+ if (parsed.malformed) return { state: "uncertain", failures: [], correlation: {}, testFailureEstablished: false, runnerError: false };
236
285
  const sum = summary(parsed.result);
237
286
  const total = number(field(sum ?? parsed.result, "total"));
238
287
  const passed = number(field(sum ?? parsed.result, "passed", "pass"));
239
288
  const failedCount = number(field(sum ?? parsed.result, "failed", "fail"));
240
289
  const inconclusive = number(field(sum ?? parsed.result, "inconclusive", "skipped"));
241
290
  const raw = statusOf(parsed.result);
242
- const semanticFailed = !parsed.outerSuccess || hasSemanticFailure(parsed.result) || (failedCount ?? 0) > 0;
243
- const state = semanticFailed || raw === "failed" || raw === "error" ? "failed" : raw === "cancelled" || raw === "canceled" ? "cancelled"
291
+ const records = testRecords(parsed.result);
292
+ const testFailureEstablished = (Number.isSafeInteger(failedCount) && (failedCount ?? 0) > 0) || records.some(test => isRecognizedFailedTestStatus(test.status));
293
+ const runnerError = !parsed.outerSuccess || raw === "failed" || raw === "error" || hasSemanticFailure(parsed.result);
294
+ // A reported active state remains active even when it carries partial records/counts.
295
+ const state = raw === "cancelled" || raw === "canceled" ? "cancelled"
244
296
  : raw === "no_tests" || raw === "idle" || raw === "not_started" || raw === "not_running" ? "inactive"
245
297
  : raw === "running" ? "running" : raw === "starting" || raw === "queued" ? "starting"
246
- : raw === "completed" || raw === "complete" || raw === "success" ? "completed" : "uncertain";
247
- return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: testRecords(parsed.result) };
298
+ : raw === "completed" || raw === "complete" || raw === "success" ? testFailureEstablished || runnerError ? "failed" : "completed"
299
+ : testFailureEstablished || runnerError ? "failed" : "uncertain";
300
+ return { state, total, passed, failed: failedCount, inconclusive, failures: testFailures(parsed.result), correlation: correlation(parsed.result), testRecords: records, testFailureEstablished, runnerError };
248
301
  }
249
302
 
250
303
  function editorStopSucceeded(output: string): boolean {
@@ -363,7 +416,7 @@ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot
363
416
 
364
417
  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
418
  const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
366
- const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
419
+ const error = capabilityError(capabilities, commands); if (error) throw new Error(error + unityCapabilityDiagnosticSuffix(capabilities));
367
420
  let editor = await executeCommand(deps, projectRoot, "editor_status", [], signal, deadline, now);
368
421
  if (editor.error) throw new Error("Unity Pipeline editor_status failed; operation not started.");
369
422
  let status = editorStatus(editor.stdout);
@@ -429,10 +482,18 @@ function checkCorrelation(expected: Record<string, string>, actual: Record<strin
429
482
  return Object.entries(expected).every(([key, value]) => !actual[key] || actual[key] === value);
430
483
  }
431
484
  function passingCounts(state: NormalizedTest): { total: number; passed: number; failed: number; inconclusive?: number } | undefined {
485
+ const summary = { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
486
+ if (!hasConsistentUnityTestCounts(summary, state.testRecords ?? [])) return undefined;
487
+ if (state.testRecords?.some(test => !/^(?:passed|success)$/i.test(test.status))) return undefined;
432
488
  if (state.total === undefined || state.total <= 0 || state.passed === undefined || state.failed !== 0 || (state.inconclusive ?? 0) > 0) return undefined;
433
489
  if (state.passed + state.failed + (state.inconclusive ?? 0) !== state.total) return undefined;
434
490
  return { total: state.total, passed: state.passed, failed: state.failed, inconclusive: state.inconclusive };
435
491
  }
492
+ function terminalEvidence(state: NormalizedTest, reason: string, request: UnityPipelineTestRequest, elapsedSeconds: number, warnings: string[]): UnityPipelineTerminalTestEvidenceError {
493
+ const outcome = state.state === "cancelled" ? "cancelled" : state.testFailureEstablished ? "tests_failed" : state.runnerError ? "run_error" : "uncertain";
494
+ const observations = [`terminal state=${state.state}`, `terminal outcome=${outcome}`, ...["total", "passed", "failed", "inconclusive"].flatMap(key => state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">] === undefined ? [] : [`reported ${key}=${String(state[key as keyof Pick<NormalizedTest, "total" | "passed" | "failed" | "inconclusive">])}`]), ...Object.entries(state.correlation).map(([key, value]) => `correlation ${key}=${value}`)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
495
+ return new UnityPipelineTerminalTestEvidenceError({ state: state.state as "completed" | "failed" | "cancelled", outcome, reason, elapsedSeconds, selection: { platform: request.testPlatform, ...(request.testFilter ?? request.testCategory ? { filter: request.testFilter ?? request.testCategory } : {}) }, correlation: state.correlation, observations, testRecords: state.testRecords ?? [], warnings });
496
+ }
436
497
  function elapsed(start: number, now: () => number): number { return Math.max(0, (now() - start) / 1000); }
437
498
  function timeoutMessage(operation: string): Error { return new Error(`Unity Pipeline ${operation} timed out; result is uncertain and may still be running. No cancellation, retry, or route switch was performed.`); }
438
499
  function ensureBeforeDeadline(deadline: number, now: () => number, operation: string): void {
@@ -450,11 +511,12 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
450
511
  ensureBeforeDeadline(deadline, now, "recompile before dispatch");
451
512
  throwIfAborted(signal);
452
513
  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.");
514
+ if (dispatched.error) throw new Error(`Unity Pipeline recompile dispatch failed; operation may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
515
+ const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
454
516
  let state = normalizeUnityPipelineCompile(dispatched.stdout);
455
517
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
456
518
  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) } };
519
+ 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
520
  for (let poll = 0; now() < deadline; poll += 1) {
459
521
  options.onUpdate?.(`Unity recompile ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
460
522
  const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(poll, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, deadline - now());
@@ -467,9 +529,10 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
467
529
  const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
468
530
  if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
469
531
  if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
532
+ retainWarnings(dispatchWarnings, response.stdout);
470
533
  state = normalizeUnityPipelineCompile(response.stdout);
471
534
  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) } };
535
+ 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
536
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile status is malformed or uncertain; operation may still be running.");
474
537
  }
475
538
  throw timeoutMessage("recompile");
@@ -498,18 +561,19 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
498
561
  const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...selectorArgs, "--async_tests", "true"];
499
562
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
500
563
  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.");
564
+ if (dispatched.error) throw new Error(`Unity Pipeline test dispatch failed; test run may not have started.${pipelineFailureDiagnostic(dispatched) ? ` ${pipelineFailureDiagnostic(dispatched)}` : ""}`);
565
+ const dispatchWarnings = pipelineEnvelopeWarnings(dispatched.stdout);
502
566
  let state = normalizeUnityPipelineTest(dispatched.stdout);
503
567
  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
- if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
505
568
  const requestedCorrelation = { mode: request.testPlatform, ...(request.testFilter ? { filter: request.testFilter } : {}) };
506
569
  if (!checkCorrelation(requestedCorrelation, state.correlation)) throw new Error("Unity Pipeline test dispatch reported a different mode or filter; operation state is uncertain.");
570
+ if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
507
571
  const expected = { ...requestedCorrelation, ...state.correlation };
508
572
  // Some Pipeline versions return a complete result directly from asynchronous dispatch.
509
573
  if (state.state === "completed") {
510
574
  const counts = passingCounts(state);
511
- 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 };
575
+ if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
576
+ 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
577
  }
514
578
  for (let poll = 0; now() < deadline; poll += 1) {
515
579
  options.onUpdate?.(`Unity ${request.testPlatform} tests ${state.state}; ${elapsed(start, now).toFixed(1)}s elapsed.`);
@@ -523,15 +587,16 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
523
587
  const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
524
588
  if (response.error) continue;
525
589
  if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
590
+ retainWarnings(dispatchWarnings, response.stdout);
526
591
  state = normalizeUnityPipelineTest(response.stdout);
527
592
  if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
528
- if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);
593
+ if (state.state === "failed" || state.state === "cancelled") throw terminalEvidence(state, `Unity ${request.testPlatform} tests ${state.state}: ${state.failures.join("; ") || state.state}.`, request, elapsed(start, now), dispatchWarnings);
529
594
  if (state.state === "uncertain") throw new Error("Unity Pipeline test status is malformed or uncertain; operation may still be running.");
530
595
  if (state.state === "inactive") throw new Error("Unity Pipeline test status became inactive before a terminal result; operation state is uncertain.");
531
596
  if (state.state !== "completed") continue;
532
597
  const counts = passingCounts(state);
533
- 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 };
598
+ if (!counts) throw terminalEvidence(state, "Unity test result is terminal but lacks passing evidence (consistent positive total, passed count, and reported zero failures).", request, elapsed(start, now), dispatchWarnings);
599
+ 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
600
  }
536
601
  throw timeoutMessage("tests");
537
602
  }
@@ -60,6 +60,8 @@ export type NormalizedUnityTestResult = {
60
60
  tests: NormalizedUnityTest[];
61
61
  flakyTests?: Array<{ name: string; attempts: number }>;
62
62
  backendArtifacts?: Record<string, string>;
63
+ /** Bounded non-authoritative observations retained when terminal Pipeline evidence cannot establish a result. */
64
+ diagnostics?: string[];
63
65
  };
64
66
 
65
67
  export type UnityTestRouteRequirements = { requiresIsolation: boolean; reasons: string[] };
@@ -250,6 +252,7 @@ export function normalizeUnityTestResult(result: NormalizedUnityTestResult): Nor
250
252
  summary: Object.fromEntries(Object.entries(result.summary).flatMap(([key, value]) => numberOrUndefined(value) === undefined ? [] : [[key, numberOrUndefined(value)!]])),
251
253
  tests, ...(result.flakyTests ? { flakyTests: result.flakyTests.slice(0, UNITY_TEST_MAX_TESTS).map(item => ({ name: bound(item.name, 1_000) || "Unnamed test", attempts: Math.max(1, Math.floor(item.attempts)) })) } : {}),
252
254
  ...(Object.keys(artifacts).length ? { backendArtifacts: artifacts } : {}),
255
+ ...(result.diagnostics ? { diagnostics: result.diagnostics.slice(0, 8).flatMap(value => typeof value === "string" ? [bound(value, 1_000) || ""] : []).filter(Boolean) } : {}),
253
256
  };
254
257
  }
255
258