@aefree/pi-unity 0.9.3 → 0.10.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
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows semantic versioning for public package releases.
7
7
 
8
+ ## [0.10.0] - 2026-08-14
9
+
10
+ ### Changed
11
+
12
+ - Added a configurable 1–86,400-second timeout to `unity_pipeline_eval`, forwarding it to both Unity CLI and the host process.
13
+ - Handle Pipeline 0.5's explicitly rejected initial-settling `Server Busy` envelopes with bounded retries for connected recompiles and tests, while preserving no-retry handling for ambiguous responses.
14
+
8
15
  ## [0.9.3] - 2026-08-10
9
16
 
10
17
  ### Added
package/README.md CHANGED
@@ -34,12 +34,12 @@ 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_pipeline_run_tests` — run one focused EditMode or PlayMode selection with bounded polling and aggregate results.
37
- - `unity_pipeline_eval` — execute bounded project-specific C# through Pipeline's Roslyn REPL.
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
38
  - `unity_pipeline_inspect` — dispatch supported package-owned inspection commands and return structured evidence.
39
39
 
40
40
  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
41
 
42
- A timeout is uncertain: work may still be running. The tools do not silently cancel, retry, launch another Editor, or switch to batchmode.
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_pipeline_run_tests`, which is known not to have dispatched a main-thread command and is retried only within the configured deadline.
43
43
 
44
44
  ### Editor and batchmode
45
45
 
package/index.ts CHANGED
@@ -148,6 +148,7 @@ const PIPELINE_TEST_PARAMS = Type.Object({
148
148
  const PIPELINE_EVAL_PARAMS = Type.Object({
149
149
  path: Type.Optional(Type.String({ maxLength: 1000, description: "Unity project path, workspace copy root, or folder containing project copies." })),
150
150
  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." }),
151
+ 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." })),
151
152
  }, { additionalProperties: false });
152
153
 
153
154
  const PIPELINE_INSPECTION_PARAMS = Type.Object({
@@ -1470,7 +1471,7 @@ export default function freeUnityPi(pi: ExtensionAPI) {
1470
1471
  }, {
1471
1472
  execute: createPlanningUnityCliExecutor(pi),
1472
1473
  signal,
1473
- timeout: 12_000,
1474
+ timeout: (params.timeoutSeconds ?? 12) * 1000,
1474
1475
  });
1475
1476
  throwIfAborted(signal);
1476
1477
  const text = result.outcome === "dispatched"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aefree/pi-unity",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -16,7 +16,7 @@ Use one typed tool call for each supported connected operation:
16
16
 
17
17
  These tools resolve the exact copy, require advertised commands, inspect lifecycle state, dispatch once, validate identity, and poll internally with a fixed deadline. Do not recreate their wait loops with `bash`, `unity recompile_status`, or `unity test_status` calls.
18
18
 
19
- A timeout or malformed response is uncertain: the Unity operation may still be running. Do not cancel, retry, launch batchmode, close the Editor, or claim a result without a new user-authorized decision.
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_pipeline_run_tests`, which confirms that a main-thread command was rejected before dispatch.
20
20
 
21
21
  ## Preconditions and boundaries
22
22
 
@@ -49,4 +49,4 @@ Normally call the typed tools, not raw CLI commands. If a typed tool is unavaila
49
49
 
50
50
  Use `unity_run_test_batch` for a closed project, intentional isolation/CI, category or multiple filters, or required NUnit XML/log evidence. State the reason for that route. Do not use batchmode as an automatic fallback after an uncertain connected dispatch.
51
51
 
52
- 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; 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.
52
+ 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.
@@ -145,6 +145,17 @@ function diagnostics(result: RecordValue): string[] {
145
145
  });
146
146
  return [...new Set(values)].slice(0, UNITY_PIPELINE_MAX_DIAGNOSTICS);
147
147
  }
148
+ /** True only for Pipeline 0.5's explicit rejected outer envelope, before a main-thread command is dispatched. */
149
+ export function isUnityPipelineInitialSettlingBusy(output: string): boolean {
150
+ let outer: RecordValue | undefined;
151
+ try { outer = record(JSON.parse(output)); } catch { return false; }
152
+ const data = record(outer?.data);
153
+ return outer?.success === false
154
+ && string(field(data ?? {}, "error"))?.toLowerCase() === "server busy"
155
+ && statusOf(data ?? {}) === "busy"
156
+ && field(data ?? {}, "retryable") === true;
157
+ }
158
+
148
159
  export function normalizeUnityPipelineCompile(output: string): NormalizedCompile {
149
160
  const parsed = parseUnityPipelineEnvelope(output);
150
161
  if (parsed.malformed) return { state: "uncertain", diagnostics: [], failed: false };
@@ -316,6 +327,19 @@ async function executeCommand(deps: PipelineDependencies, projectRoot: string, c
316
327
  if (deadline !== undefined) ensureBeforeDeadline(deadline, now, command);
317
328
  return result;
318
329
  }
330
+ async function dispatchMainThreadCommand(deps: PipelineDependencies, projectRoot: string, command: "recompile" | "run_tests", args: string[], operation: string, signal: AbortSignal | undefined, deadline: number, now: () => number, sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>): Promise<UnityCliExecResult> {
331
+ for (let attempt = 0; ; attempt += 1) {
332
+ const response = await executeCommand(deps, projectRoot, command, args, signal, deadline, now);
333
+ if (!isUnityPipelineInitialSettlingBusy(response.stdout)) return response;
334
+ const remaining = deadline - now();
335
+ if (remaining <= 0) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
336
+ const delay = Math.min(UNITY_PIPELINE_BACKOFF_SECONDS[Math.min(attempt, UNITY_PIPELINE_BACKOFF_SECONDS.length - 1)]! * 1000, remaining);
337
+ await sleep(delay, signal);
338
+ throwIfAborted(signal);
339
+ if (now() >= deadline) throw new Error(`Unity Pipeline server remained busy while settling; ${operation} was not started before the deadline.`);
340
+ }
341
+ }
342
+
319
343
  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 }> {
320
344
  const capabilities = await inspectWithDeadline(deps, projectRoot, unityVersion, signal, deadline, now, "preflight");
321
345
  const error = capabilityError(capabilities, commands); if (error) throw new Error(error);
@@ -404,7 +428,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
404
428
  const lifecyclePrefix = playModeOutcomeText(preflight);
405
429
  ensureBeforeDeadline(deadline, now, "recompile before dispatch");
406
430
  throwIfAborted(signal);
407
- const dispatched = await executeCommand(deps, projectRoot, "recompile", [], signal, deadline, now);
431
+ const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "recompile", [], "recompile", signal, deadline, now, sleep);
408
432
  if (dispatched.error) throw new Error("Unity Pipeline recompile dispatch failed; operation may not have started.");
409
433
  let state = normalizeUnityPipelineCompile(dispatched.stdout);
410
434
  if (state.state === "uncertain") throw new Error("Unity Pipeline recompile dispatch returned malformed or uncertain evidence; operation may have started.");
@@ -421,6 +445,7 @@ export async function runUnityPipelineRecompile(request: UnityPipelineCompileReq
421
445
  if (identityState === "temporary_disconnect") continue;
422
446
  const response = await executeCommand(deps, projectRoot, "recompile_status", [], signal, deadline, now);
423
447
  if (response.error) continue; // Domain reload can briefly disconnect the same exact copy.
448
+ if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
424
449
  state = normalizeUnityPipelineCompile(response.stdout);
425
450
  if (state.state === "failed") throw new Error(`Unity recompile failed: ${state.diagnostics.join("; ") || "compiler failure reported"}`);
426
451
  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) } };
@@ -448,7 +473,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
448
473
  }
449
474
  const args = ["--mode", request.testPlatform === "EditMode" ? "editor" : "playmode", ...(request.testFilter ? ["--filter", request.testFilter, "--filter_type", "testName"] : []), "--async_tests", "true"];
450
475
  ensureBeforeDeadline(deadline, now, "tests before dispatch"); throwIfAborted(signal);
451
- const dispatched = await executeCommand(deps, projectRoot, "run_tests", args, signal, deadline, now);
476
+ const dispatched = await dispatchMainThreadCommand(deps, projectRoot, "run_tests", args, "tests", signal, deadline, now, sleep);
452
477
  if (dispatched.error) throw new Error("Unity Pipeline test dispatch failed; test run may not have started.");
453
478
  let state = normalizeUnityPipelineTest(dispatched.stdout);
454
479
  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.");
@@ -473,6 +498,7 @@ export async function runUnityPipelineTests(request: UnityPipelineTestRequest, d
473
498
  if (identityState === "temporary_disconnect") continue;
474
499
  const response = await executeCommand(deps, projectRoot, "test_status", [], signal, deadline, now);
475
500
  if (response.error) continue;
501
+ if (isUnityPipelineInitialSettlingBusy(response.stdout)) continue;
476
502
  state = normalizeUnityPipelineTest(response.stdout);
477
503
  if (!checkCorrelation(expected, state.correlation)) throw new Error("Unity Pipeline test status was displaced by a different run; operation state is uncertain.");
478
504
  if (state.state === "failed" || state.state === "cancelled") throw new Error(`Unity ${request.testPlatform} tests failed: ${state.failures.join("; ") || state.state}.`);